Merge pull request #12 from jambonz/feat/advance

Feat/advance
This commit is contained in:
Dave Horton
2023-10-25 08:39:54 -04:00
committed by GitHub
26 changed files with 929 additions and 254 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
src/api/constants.ts Normal file
View File

@@ -0,0 +1 @@
export const MSG_SOMETHING_WRONG = "Something went wrong, please try again";

146
src/api/index.ts Normal file
View File

@@ -0,0 +1,146 @@
import {
Application,
FetchError,
FetchTransport,
Queue,
StatusCodes,
} from "./types";
import { MSG_SOMETHING_WRONG } from "./constants";
import { getAdvancedSettings } from "src/storage";
const fetchTransport = <Type>(
url: string,
options: RequestInit
): Promise<FetchTransport<Type>> => {
return new Promise(async (resolve, reject) => {
try {
const response = await fetch(url, options);
const transport: FetchTransport<Type> = {
headers: response.headers,
status: response.status,
json: <Type>{},
};
// Redirect unauthorized
if (response.status === StatusCodes.UNAUTHORIZED) {
reject();
}
// API error handling returns { msg: string; }
// See @type StatusJSON and StatusEmpty in ./types
if (
response.status >= StatusCodes.BAD_REQUEST &&
response.status <= StatusCodes.INTERNAL_SERVER_ERROR
) {
try {
const errJson = await response.json();
reject(<FetchError>{
status: response.status,
...errJson,
});
} catch (error) {
reject(<FetchError>{
status: response.status,
msg: MSG_SOMETHING_WRONG,
});
}
}
// API success handling returns a valid JSON response
// This could either be a DTO object or a generic response
// See types for various responses in ./types
if (
response.status === StatusCodes.OK ||
response.status === StatusCodes.CREATED
) {
// Handle blobs -- e.g. pcap file API for RecentCalls
if (
options.headers!["Content-Type" as keyof HeadersInit] ===
"application/octet-stream"
) {
const blob: Blob = await response.blob();
transport.blob = blob;
} else {
const json: Type = await response.json();
transport.json = json;
}
}
resolve(transport);
// TypeError "Failed to fetch"
// net::ERR_CONNECTION_REFUSED
// This is the case if the server is unreachable...
} catch (error: unknown) {
reject(<FetchError>{
status: StatusCodes.INTERNAL_SERVER_ERROR,
msg: (error as TypeError).message,
});
}
});
};
const getAuthHeaders = () => {
const advancedSettings = getAdvancedSettings();
let token = advancedSettings.apiKey ?? null;
return {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
};
};
export const getFetch = <Type>(url: string) => {
return fetchTransport<Type>(url, {
headers: getAuthHeaders(),
});
};
export const postFetch = <Type, Payload = undefined>(
url: string,
payload?: Payload
) => {
return fetchTransport<Type>(url, {
method: "POST",
...(payload && { body: JSON.stringify(payload) }),
headers: getAuthHeaders(),
});
};
export const putFetch = <Type, Payload>(url: string, payload: Payload) => {
return fetchTransport<Type>(url, {
method: "PUT",
body: JSON.stringify(payload),
headers: getAuthHeaders(),
});
};
export const deleteFetch = <Type>(url: string) => {
return fetchTransport<Type>(url, {
method: "DELETE",
headers: getAuthHeaders(),
});
};
// GET Devices Users
export const getRegisteredUser = () => {
const advancedSettings = getAdvancedSettings();
return getFetch<string[]>(
`${advancedSettings.apiServer}/Accounts/${advancedSettings.accountSid}/RegisteredSipUsers`
);
};
export const getApplications = () => {
const advancedSettings = getAdvancedSettings();
return getFetch<Application[]>(
`${advancedSettings.apiServer}/Accounts/${advancedSettings.accountSid}/Applications`
);
};
export const getQueues = () => {
const advancedSettings = getAdvancedSettings();
return getFetch<Queue[]>(
`${advancedSettings.apiServer}/Accounts/${advancedSettings.accountSid}/Queues`
);
};

36
src/api/types.ts Normal file
View File

@@ -0,0 +1,36 @@
export enum StatusCodes {
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NO_CONTENT = 204,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
UNPROCESSABLE_ENTITY = 422,
INTERNAL_SERVER_ERROR = 500,
/** SMPP temporarily unavailable */
TEMPORARILY_UNAVAILABLE = 480,
}
export interface FetchTransport<Type> {
headers: Headers;
status: StatusCodes;
json: Type;
blob?: Blob;
}
export interface FetchError {
status: StatusCodes;
msg: string;
}
export interface Application {
application_sid: string;
name: string;
}
export interface Queue {
name: string;
length: number;
}

View File

@@ -55,13 +55,19 @@ export interface AppSettings {
sipUsername: string;
sipPassword: string;
sipDisplayName: string;
}
export interface AdvancedAppSettings {
accountSid: string;
apiKey: string;
apiServer: string;
}
export interface CallHistory {
callSid: string;
direction: SipCallDirection;
number: string;
name?: string;
duration: string;
timeStamp: number;
isSaved?: boolean;

View File

@@ -0,0 +1,70 @@
import {
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Spinner,
Tooltip,
} from "@chakra-ui/react";
import { useState } from "react";
export interface IconButtonMenuItems {
name: string;
value: string;
}
type IconButtonMenuProbs = {
icon: React.ReactElement;
onOpen: () => Promise<IconButtonMenuItems[]>;
onClick: (name: string, value: string) => void;
tooltip: string;
noResultLabel: string;
};
export const IconButtonMenu = ({
icon,
onOpen,
onClick,
tooltip,
noResultLabel,
}: IconButtonMenuProbs) => {
const [items, setItems] = useState<IconButtonMenuItems[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleOnOpen = () => {
setIsLoading(true);
onOpen()
.then((values) => setItems(values))
.finally(() => setIsLoading(false));
};
return (
<Menu onOpen={handleOnOpen}>
<Tooltip label={tooltip}>
<MenuButton
as={IconButton}
aria-label="Options"
icon={icon}
variant="unstyled"
/>
</Tooltip>
<MenuList>
{isLoading ? (
<MenuItem>
<Spinner color="jambonz.500" size="xs" />
</MenuItem>
) : items.length > 0 ? (
items.map((i, idx) => (
<MenuItem key={idx} onClick={() => onClick(i.name, i.value)}>
{i.name}
</MenuItem>
))
) : (
<MenuItem>{noResultLabel}</MenuItem>
)}
</MenuList>
</Menu>
);
};
export default IconButtonMenu;

View File

@@ -11,11 +11,13 @@ import { Eye, EyeOff } from "react-feather";
type PasswordInputProbs = {
password: [string, React.Dispatch<React.SetStateAction<string>>];
placeHolder?: string;
isRequired?: boolean;
};
function PasswordInput({
password: [pass, setPass],
placeHolder,
isRequired = false,
}: PasswordInputProbs) {
const [showPassword, setShowPassword] = useState(false);
const handleClick = () => setShowPassword(!showPassword);
@@ -27,6 +29,7 @@ function PasswordInput({
type={showPassword ? "text" : "password"}
placeholder={placeHolder || ""}
value={pass}
isRequired
onChange={(e) => setPass(e.target.value)}
/>
<InputRightElement width="4.5rem">

View File

@@ -1,17 +1,26 @@
export default class SipAudioElements {
#ringing: HTMLAudioElement;
#ringBack: HTMLAudioElement;
#failed: HTMLAudioElement;
#answer: HTMLAudioElement;
#busy: HTMLAudioElement;
#remote: HTMLAudioElement;
#hungup: HTMLAudioElement;
constructor() {
this.#ringing = new Audio(chrome.runtime.getURL("audios/ringing.mp3"));
this.#ringing.loop = true;
this.#ringing.volume = 0.8;
this.#failed = new Audio(chrome.runtime.getURL("audios/failed.mp3"));
this.#ringBack = new Audio(chrome.runtime.getURL("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.volume = 0.3;
this.#answer = new Audio(chrome.runtime.getURL("audios/failed.mp3"));
this.#answer.volume = 0.3;
this.#busy = new Audio(chrome.runtime.getURL("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.volume = 0.3;
this.#remote = new Audio();
}
@@ -28,20 +37,38 @@ export default class SipAudioElements {
}
}
playRingback(volume: number | undefined): void {
if (volume) {
this.#ringBack.volume = volume;
}
this.#ringBack.play();
}
pauseRingback(): void {
if (!this.#ringBack.paused) {
this.#ringBack.pause();
}
}
playFailed(volume: number | undefined): void {
this.pauseRinging();
this.pauseRingback();
if (volume) {
this.#failed.volume = volume;
}
this.#failed.play();
}
playRemotePartyHungup(volume: number | undefined): void {
if (volume) {
this.#hungup.volume = volume;
}
this.#hungup.play();
}
playAnswer(volume: number | undefined): void {
this.pauseRinging();
// if (volume) {
// this.#answer.volume = volume;
// }
// this.#answer.play();
this.pauseRingback();
}
isRemoteAudioPaused(): boolean {
@@ -53,6 +80,11 @@ export default class SipAudioElements {
this.#remote.play();
}
stopAll() {
this.pauseRinging();
this.pauseRingback();
}
isPLaying(audio: HTMLAudioElement) {
return (
audio.currentTime > 0 &&

View File

@@ -57,6 +57,8 @@ export default class SipSession extends events.EventEmitter {
});
if (this.#audio.isRemoteAudioPaused() && !this.replaces) {
this.#audio.playRinging(undefined);
} else {
this.#audio.playRingback(undefined);
}
});
@@ -94,12 +96,16 @@ export default class SipSession extends events.EventEmitter {
this.#audio.playFailed(undefined);
} else {
this.#audio.pauseRinging();
this.#audio.pauseRingback();
}
});
this.#rtcSession.on("ended", (data: EndEvent): void => {
const { originator, cause, message } = data;
let description;
if (originator === "remote") {
this.#audio.playRemotePartyHungup(undefined);
}
if (message && originator === "remote" && message.hasHeader("Reason")) {
const reason = Grammar.parse(message.getHeader("Reason"), "Reason");
if (reason) {

View File

@@ -126,10 +126,10 @@ export default class SipUA extends events.EventEmitter {
this.emit(SipConstants.UA_STOP);
}
call(number: string): void {
call(number: string, customHeaders: string[] = []): void {
let normalizedNumber: string = normalizeNumber(number);
this.#ua.call(normalizedNumber, {
extraHeaders: [`X-Original-Number:${number}`],
extraHeaders: [`X-Original-Number:${number}`].concat(customHeaders),
mediaConstraints: { audio: true, video: false },
pcConfig: this.#rtcConfig,
});

View File

@@ -1,23 +1,24 @@
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, '');
}
if (/^(sips?|tel):/i.test(number)) {
return number;
} else if (/@/i.test(number)) {
return number;
} else if (number.startsWith("app-") || number.startsWith("queue-")) {
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;
}
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
}
export { normalizeNumber, randomId };

View File

@@ -1,4 +1,8 @@
import { AppSettings, CallHistory } from "src/common/types";
import {
AdvancedAppSettings,
AppSettings,
CallHistory,
} from "src/common/types";
import { Buffer } from "buffer";
// Settings
@@ -21,6 +25,26 @@ export const getSettings = (): AppSettings => {
return {} as AppSettings;
};
// Advanced settings
const ADVANCED_SETTINGS_KET = "AdvancedSettingsKey";
export const saveAddvancedSettings = (settings: AdvancedAppSettings) => {
const encoded = Buffer.from(JSON.stringify(settings), "utf-8").toString(
"base64"
);
localStorage.setItem(ADVANCED_SETTINGS_KET, encoded);
};
export const getAdvancedSettings = (): AdvancedAppSettings => {
const str = localStorage.getItem(ADVANCED_SETTINGS_KET);
if (str) {
const planText = Buffer.from(str, "base64").toString("utf-8");
return JSON.parse(planText) as AdvancedAppSettings;
}
return {} as AdvancedAppSettings;
};
// Call History
const historyKey = "History";
const MAX_HISTORY_COUNT = 20;

View File

@@ -58,3 +58,11 @@ export const isSipClientIdle = (callStatus: string) => {
callStatus === SipConstants.SESSION_FAILED
);
};
export const normalizeUrl = (input: string): string => {
// Extract the domain name
const url = new URL(input.startsWith("http") ? input : `https://${input}`);
// Return the fully formed URL
return `${url.protocol}//${url.hostname}/api/v1`;
};

View File

@@ -15,11 +15,15 @@ import Phone from "./phone";
import Settings from "./settings";
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
import { useEffect, useState } from "react";
import { getCallHistories, getSettings } from "src/storage";
import {
getAdvancedSettings,
getCallHistories,
getSettings,
} from "src/storage";
import jambonz from "src/imgs/jambonz.svg";
import CallHistories from "./history";
import { CallHistory } from "src/common/types";
import { AdvancedAppSettings, CallHistory } from "src/common/types";
export const WindowApp = () => {
const [sipDomain, setSipDomain] = useState("");
@@ -29,7 +33,11 @@ export const WindowApp = () => {
const [sipDisplayName, setSipDisplayName] = useState("");
const [callHistories, setCallHistories] = useState<CallHistory[]>([]);
const [calledNumber, setCalledNumber] = useState("");
const [calledName, setCalledName] = useState("");
const [tabIndex, setTabIndex] = useState(0);
const [advancedSettings, setAdvancedSettings] = useState<AdvancedAppSettings>(
getAdvancedSettings()
);
const tabsSettings = [
{
title: "Dialer",
@@ -41,17 +49,20 @@ export const WindowApp = () => {
sipDisplayName={sipDisplayName}
sipServerAddress={sipServerAddress}
calledNumber={[calledNumber, setCalledNumber]}
calledName={[calledName, setCalledName]}
advancedSettings={advancedSettings}
/>
),
},
{
title: "History",
title: "Calls",
content: (
<CallHistories
calls={callHistories}
onDataChange={() => setCallHistories(getCallHistories(sipUsername))}
onCallNumber={(number) => {
onCallNumber={(number, name) => {
setCalledNumber(number);
setCalledName(name || "");
setTabIndex(0);
}}
/>
@@ -75,6 +86,7 @@ export const WindowApp = () => {
const loadSettings = () => {
const settings = getSettings();
setAdvancedSettings(getAdvancedSettings());
setSipDomain(settings.sipDomain);
setSipServerAddress(settings.sipServerAddress);
setSipUsername(settings.sipUsername);

View File

@@ -9,7 +9,13 @@ import {
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { useState } from "react";
import { Phone, PhoneIncoming, PhoneOutgoing, Save, Star } from "react-feather";
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";
@@ -17,13 +23,15 @@ import { formatPhoneNumber } from "src/utils";
type CallHistoryItemProbs = {
call: CallHistory;
onDataChange?: (call: CallHistory) => void;
onCallNumber?: (number: string) => void;
onCallNumber?: (number: string, name: string | undefined) => void;
isSaved?: boolean;
};
export const CallHistoryItem = ({
call,
onDataChange,
onCallNumber,
isSaved,
}: CallHistoryItemProbs) => {
const [callEnable, setCallEnable] = useState(false);
const getDirectionIcon = (direction: SipCallDirection) => {
@@ -45,21 +53,14 @@ export const CallHistoryItem = ({
onMouseEnter={() => setCallEnable(true)}
onMouseLeave={() => setCallEnable(false)}
>
<Icon as={getDirectionIcon(call.direction)} w="20px" h="20px" />
<VStack align="start">
<Text fontSize="14px" fontWeight="500">
{formatPhoneNumber(call.number)}
</Text>
<Text fontSize="12px">{call.duration}</Text>
</VStack>
{callEnable && (
{callEnable ? (
<Tooltip label="Call">
<IconButton
aria-label="call recents"
icon={<Phone />}
onClick={() => {
if (onCallNumber) {
onCallNumber(call.number);
onCallNumber(call.number, call.name);
}
}}
variant="unstyled"
@@ -67,18 +68,31 @@ export const CallHistoryItem = ({
color="green.500"
/>
</Tooltip>
) : (
<Icon as={getDirectionIcon(call.direction)} w="20px" h="20px" />
)}
<VStack align="start">
<Text fontSize="14px" fontWeight="500">
{call.name || formatPhoneNumber(call.number)}
</Text>
<Text fontSize="12px">{call.duration}</Text>
</VStack>
<Spacer />
<VStack align="start">
<Text fontSize="12px">
{dayjs(call.timeStamp).format("MMM D, hh:mm A")}
</Text>
</VStack>
<Tooltip label="Save">
<Tooltip label={isSaved && call.isSaved ? "Delete" : "Save"}>
<IconButton
aria-label="save recents"
icon={<Save />}
icon={isSaved && call.isSaved ? <Trash2 /> : <Save />}
onClick={() => {
if (!isSaved && call.isSaved) {
return;
}
const settings = getSettings();
if (settings.sipUsername) {
isSaveCallHistory(

View File

@@ -1,5 +1,4 @@
import {
VStack,
Grid,
HStack,
InputGroup,
@@ -7,18 +6,15 @@ import {
InputLeftElement,
Icon,
Text,
Spacer,
UnorderedList,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Search, Sliders } from "react-feather";
import { CallHistory } from "src/common/types";
import CallHistoryItem from "./call-history-item";
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
import Recents from "./recent";
@@ -26,7 +22,7 @@ import Recents from "./recent";
type CallHistoriesProbs = {
calls: CallHistory[];
onDataChange?: (call: CallHistory) => void;
onCallNumber?: (number: string) => void;
onCallNumber?: (number: string, name: string | undefined) => void;
};
export const CallHistories = ({
@@ -39,8 +35,8 @@ export const CallHistories = ({
return (
<Tabs isFitted colorScheme={DEFAULT_COLOR_SCHEME}>
<TabList mb="1em" gap={1}>
<Tab>Saved</Tab>
<Tab>Recent</Tab>
<Tab>Saved</Tab>
</TabList>
<Grid w="full" templateColumns="1fr auto" gap={5}>
@@ -71,7 +67,6 @@ export const CallHistories = ({
<Recents
calls={calls}
search={searchText}
isSaved
onCallNumber={onCallNumber}
onDataChange={onDataChange}
/>
@@ -80,6 +75,7 @@ export const CallHistories = ({
<Recents
calls={calls}
search={searchText}
isSaved
onCallNumber={onCallNumber}
onDataChange={onDataChange}
/>

View File

@@ -9,7 +9,7 @@ type RecentsProbs = {
search: string;
isSaved?: boolean;
onDataChange?: (call: CallHistory) => void;
onCallNumber?: (number: string) => void;
onCallNumber?: (number: string, name: string | undefined) => void;
};
export const Recents = ({
@@ -53,6 +53,7 @@ export const Recents = ({
>
{callHistories.map((c) => (
<CallHistoryItem
isSaved={isSaved}
call={c}
onCallNumber={onCallNumber}
onDataChange={onDataChange}
@@ -61,7 +62,7 @@ export const Recents = ({
</UnorderedList>
) : (
<Text fontSize="24px" fontWeight="bold">
No Call History
{isSaved ? "No saved calls" : "No Call History"}
</Text>
)}
</VStack>

View File

@@ -14,13 +14,25 @@ import {
VStack,
} from "@chakra-ui/react";
import { useEffect, useRef, useState } from "react";
import { Mic, MicOff, Pause, PhoneOff, Play } from "react-feather";
import { Call, SipCallDirection, SipClientStatus } from "src/common/types";
import {
GitMerge,
List,
Mic,
MicOff,
Pause,
PhoneOff,
Play,
Users,
} from "react-feather";
import {
AdvancedAppSettings,
SipCallDirection,
SipClientStatus,
} from "src/common/types";
import { SipConstants, SipUA } from "src/lib";
import IncommingCall from "./incomming-call";
import DialPad from "./dial-pad";
import {
formatPhoneNumber,
isSipClientAnswered,
isSipClientIdle,
isSipClientRinging,
@@ -31,12 +43,15 @@ import GreenAvatar from "src/imgs/icons/Avatar-Green.svg";
import "./styles.scss";
import {
deleteCurrentCall,
getAdvancedSettings,
getCurrentCall,
saveCallHistory,
saveCurrentCall,
} from "src/storage";
import { OutGoingCall } from "./outgoing-call";
import { v4 as uuidv4 } from "uuid";
import IconButtonMenu, { IconButtonMenuItems } from "src/components/menu";
import { getApplications, getQueues, getRegisteredUser } from "src/api";
type PhoneProbs = {
sipDomain: string;
@@ -45,6 +60,8 @@ type PhoneProbs = {
sipPassword: string;
sipDisplayName: string;
calledNumber: [string, React.Dispatch<React.SetStateAction<string>>];
calledName: [string, React.Dispatch<React.SetStateAction<string>>];
advancedSettings: AdvancedAppSettings;
};
export const Phone = ({
@@ -54,8 +71,11 @@ export const Phone = ({
sipPassword,
sipDisplayName,
calledNumber: [calledANumber, setCalledANumber],
calledName: [calledAName, setCalledAName],
advancedSettings,
}: PhoneProbs) => {
const [inputNumber, setInputNumber] = useState("");
const [appName, setAppName] = useState("");
const inputNumberRef = useRef(inputNumber);
const [status, setStatus] = useState<SipClientStatus>("offline");
const [isConfigured, setIsConfigured] = useState(false);
@@ -70,10 +90,27 @@ export const Phone = ({
const [isStatusDropdownDisabled, setIsStatusDropdownDisabled] =
useState(false);
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("");
useEffect(() => {
sipDomainRef.current = sipDomain;
sipUsernameRef.current = sipUsername;
sipPasswordRef.current = sipPassword;
sipServerAddressRef.current = sipServerAddress;
sipDisplayNameRef.current = sipDisplayName;
if (sipDomain && sipUsername && sipPassword && sipServerAddress) {
createSipClient();
if (sipUA.current) {
clientGoOffline();
isRestartRef.current = true;
} else {
createSipClient();
}
setIsConfigured(true);
} else {
setIsConfigured(false);
@@ -81,6 +118,11 @@ export const Phone = ({
}
}, [sipDomain, sipUsername, sipPassword, sipServerAddress, sipDisplayName]);
useEffect(() => {
const advancedSettings = getAdvancedSettings();
setIsAdvancedMode(!!advancedSettings.accountSid);
}, [advancedSettings]);
useEffect(() => {
inputNumberRef.current = inputNumber;
sessionDirectionRef.current = sessionDirection;
@@ -101,9 +143,18 @@ export const Phone = ({
useEffect(() => {
if (calledANumber) {
setInputNumber(calledANumber);
makeOutboundCall(calledANumber);
if (
!(
calledANumber.startsWith("app-") || calledANumber.startsWith("queue-")
)
) {
setInputNumber(calledANumber);
}
setAppName(calledAName);
makeOutboundCall(calledANumber, calledAName);
setCalledANumber("");
setCalledAName("");
}
}, [calledANumber]);
@@ -146,19 +197,17 @@ export const Phone = ({
};
const createSipClient = () => {
clientGoOffline();
const client = {
username: `${sipUsername}@${sipDomain}`,
password: sipPassword,
name: sipDisplayName ?? sipUsername,
username: `${sipUsernameRef.current}@${sipDomainRef.current}`,
password: sipPasswordRef.current,
name: sipDisplayNameRef.current ?? sipUsernameRef.current,
};
const settings = {
pcConfig: {
iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }],
},
wsUri: sipServerAddress,
wsUri: sipServerAddressRef.current,
register: true,
};
@@ -170,7 +219,12 @@ export const Phone = ({
});
sipClient.on(SipConstants.UA_UNREGISTERED, (args) => {
setStatus("offline");
clientGoOffline();
if (isRestartRef.current) {
createSipClient();
isRestartRef.current = false;
} else {
clientGoOffline();
}
});
// Call Status
sipClient.on(SipConstants.SESSION_RINGING, (args) => {
@@ -222,6 +276,7 @@ export const Phone = ({
duration: transform(Date.now(), call.timeStamp),
timeStamp: call.timeStamp,
callSid: call.callSid,
name: call.name,
});
}
deleteCurrentCall();
@@ -254,19 +309,27 @@ export const Phone = ({
makeOutboundCall(inputNumber);
};
const makeOutboundCall = (number: string) => {
const makeOutboundCall = (number: string, name: string = "") => {
if (sipUA.current && number) {
setIsCallButtonLoading(true);
setCallStatus(SipConstants.SESSION_RINGING);
setSessionDirection("outgoing");
saveCurrentCall({
number: number,
name,
direction: "outgoing",
timeStamp: Date.now(),
duration: "0",
callSid: uuidv4(),
});
sipUA.current.call(number);
// Add custom header if this is special jambonz call
let customHeaders: string[] = [];
if (number.startsWith("app-")) {
customHeaders = [
`X-Application-Sid: ${number.substring(4, number.length)}`,
];
}
sipUA.current.call(number, customHeaders);
}
};
@@ -339,7 +402,7 @@ export const Phone = ({
<VStack spacing={2} alignItems="start" w="full">
<HStack spacing={2} w="full">
<Text fontWeight="bold" fontSize="13px">
{sipDisplayName ?? sipUsername}
{sipDisplayName || sipUsername}
</Text>
<Circle size="8px" bg={isOnline() ? "green.500" : "gray.500"} />
<Select
@@ -377,35 +440,122 @@ export const Phone = ({
decline={handleDecline}
/>
) : (
<OutGoingCall number={inputNumber} cancelCall={handleDecline} />
<OutGoingCall
number={inputNumber || appName}
cancelCall={handleDecline}
/>
)
) : (
<VStack
spacing={4}
spacing={2}
w="full"
mt={5}
className={isOnline() ? "" : "blurred"}
>
{isSipClientIdle(callStatus) ? (
<Input
value={inputNumber}
bg="grey.500"
fontWeight="bold"
fontSize="24px"
onChange={(e) => setInputNumber(e.target.value)}
textAlign="center"
/>
) : (
<VStack>
<Text fontSize="22px" fontWeight="bold">
{formatPhoneNumber(inputNumber)}
</Text>
{seconds >= 0 && (
<Text fontSize="15px">
{new Date(seconds * 1000).toISOString().substr(11, 8)}
</Text>
)}
</VStack>
{isAdvanceMode && isSipClientIdle(callStatus) && (
<HStack spacing={2} align="start" w="full">
<IconButtonMenu
icon={<Users />}
tooltip="Call an online user"
noResultLabel="No one else is online"
onClick={(_, value) => {
setInputNumber(value);
makeOutboundCall(value);
}}
onOpen={() => {
return new Promise<IconButtonMenuItems[]>(
(resolve, reject) => {
getRegisteredUser()
.then(({ json }) => {
resolve(
json
.filter((u) => !u.includes(sipUsername))
.map((u) => {
const uName = u.match(/(^.*)@.*/);
return {
name: uName ? uName[1] : u,
value: uName ? uName[1] : u,
};
})
);
})
.catch((err) => reject(err));
}
);
}}
/>
<IconButtonMenu
icon={<List />}
tooltip="Take a call from queue"
noResultLabel="No calls in queue"
onClick={(name, value) => {
setAppName(`Queue ${name}`);
const calledQueue = `queue-${value}`;
setInputNumber("");
makeOutboundCall(calledQueue, `Queue ${name}`);
}}
onOpen={() => {
return new Promise<IconButtonMenuItems[]>(
(resolve, reject) => {
getQueues()
.then(({ json }) => {
resolve(
json.map((q) => ({
name: `${q.name} (${q.length})`,
value: q.name,
}))
);
})
.catch((err) => reject(err));
}
);
}}
/>
<IconButtonMenu
icon={<GitMerge />}
tooltip="Call an application"
noResultLabel="No applications"
onClick={(name, value) => {
setAppName(`App ${name}`);
const calledAppId = `app-${value}`;
setInputNumber("");
makeOutboundCall(calledAppId, `App ${name}`);
}}
onOpen={() => {
return new Promise<IconButtonMenuItems[]>(
(resolve, reject) => {
getApplications()
.then(({ json }) => {
resolve(
json.map((a) => ({
name: a.name,
value: a.application_sid,
}))
);
})
.catch((err) => reject(err));
}
);
}}
/>
</HStack>
)}
<Input
value={inputNumber}
bg="grey.500"
fontWeight="bold"
fontSize="24px"
onChange={(e) => setInputNumber(e.target.value)}
textAlign="center"
isReadOnly={!isSipClientIdle(callStatus)}
/>
{!isSipClientIdle(callStatus) && seconds >= 0 && (
<Text fontSize="15px">
{new Date(seconds * 1000).toISOString().substr(11, 8)}
</Text>
)}
<DialPad handleDigitPress={handleDialPadClick} />

View File

@@ -0,0 +1,148 @@
import {
Button,
FormControl,
FormLabel,
HStack,
Icon,
Image,
Input,
Spacer,
Text,
VStack,
} from "@chakra-ui/react";
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";
export const AdvancedSettings = () => {
const [apiKey, setApiKey] = useState("");
const [apiServer, setApiServer] = useState("");
const [accountSid, setAccountSid] = useState("");
const [isCredentialOk, setIsCredentialOk] = useState(false);
const [isAdvancedMode, setIsAdvancedMode] = useState(false);
useEffect(() => {
const settings = getAdvancedSettings();
if (settings.apiServer) {
setIsAdvancedMode(true);
checkCredential();
setApiServer(settings.apiServer);
}
if (settings.apiKey) {
setApiKey(settings.apiKey);
}
if (settings.accountSid) {
setAccountSid(settings.accountSid);
}
}, []);
const checkCredential = () => {
getApplications()
.then(() => {
setIsCredentialOk(true);
})
.catch(() => {
setIsCredentialOk(false);
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setApiServer(normalizeUrl(apiServer));
const settings: AdvancedAppSettings = {
accountSid,
apiKey,
apiServer: normalizeUrl(apiServer),
};
saveAddvancedSettings(settings);
setIsAdvancedMode(true);
checkCredential();
};
const resetSetting = () => {
saveAddvancedSettings({} as AdvancedAppSettings);
setApiKey("");
setApiServer("");
setAccountSid("");
setIsAdvancedMode(false);
};
return (
<form onSubmit={handleSubmit}>
<VStack spacing={2} w="full" h="full" p={0}>
<VStack
spacing={2}
maxH="calc(100vh - 25em)"
overflowY="auto"
w="full"
p={0}
>
<FormControl id="jambonz_api_server">
<FormLabel>Jambonz API Server Base URL</FormLabel>
<Input
type="text"
placeholder="https://jambonz.cloud/api"
isRequired
value={apiServer}
onChange={(e) => setApiServer(e.target.value)}
/>
</FormControl>
<FormControl id="jambonz_account_sid">
<FormLabel>Jambonz Account Sid</FormLabel>
<Input
type="text"
isRequired
value={accountSid}
onChange={(e) => setAccountSid(e.target.value)}
/>
</FormControl>
<FormControl id="api_key">
<FormLabel>API Key</FormLabel>
<PasswordInput password={[apiKey, setApiKey]} isRequired />
</FormControl>
</VStack>
{isAdvancedMode && (
<HStack w="full" mt={2} mb={2}>
<Icon
as={isCredentialOk ? CheckCircle : XCircle}
color={isCredentialOk ? "green.500" : "red.500"}
boxSize={6}
/>
<Text
fontSize="14px"
color={isCredentialOk ? "green.500" : "red.500"}
>
Credential is {isCredentialOk ? "valid" : "invalid"}
</Text>
</HStack>
)}
<Button colorScheme="jambonz" type="submit" w="full">
Save
</Button>
<VStack w="full" align="center" mt={2}>
{/* <HStack spacing={1}>
<Image src={InfoIcon} w="30px" h="30px" />
<Text fontSize="14px">Get help</Text>
</HStack> */}
{/* <Spacer /> */}
<HStack spacing={1}>
<Image src={ResetIcon} w="30px" h="30px" />
<Text fontSize="14px" onClick={resetSetting} cursor="pointer">
Reset settings
</Text>
</HStack>
</VStack>
</VStack>
</form>
);
};
export default AdvancedSettings;

View File

@@ -0,0 +1,162 @@
import {
Box,
Button,
FormControl,
FormLabel,
HStack,
Image,
Input,
Spacer,
Text,
VStack,
useToast,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import PasswordInput from "src/components/password-input";
import { getSettings, saveSettings } from "src/storage";
import InfoIcon from "src/imgs/icons/Info.svg";
import ResetIcon from "src/imgs/icons/Reset.svg";
import { AppSettings } from "src/common/types";
import { DEFAULT_TOAST_DURATION } from "src/common/constants";
export const BasicSettings = () => {
const [sipDomain, setSipDomain] = useState("");
const [sipServerAddress, setSipServerAddress] = useState("");
const [sipUsername, setSipUsername] = useState("");
const [sipPassword, setSipPassword] = useState("");
const [sipDisplayName, setSipDisplayName] = useState("");
const toast = useToast();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const settings: AppSettings = {
sipDomain,
sipServerAddress: sipServerAddress,
sipUsername,
sipPassword,
sipDisplayName,
};
saveSettings(settings);
toast({
title: "Settings saved successfully",
status: "success",
duration: DEFAULT_TOAST_DURATION,
isClosable: true,
colorScheme: "jambonz",
});
};
const resetSetting = () => {
saveSettings({} as AppSettings);
setSipDomain("");
setSipServerAddress("");
setSipUsername("");
setSipPassword("");
setSipDisplayName("");
};
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);
}
}, []);
return (
<form onSubmit={handleSubmit}>
<VStack spacing={2} w="full" p={0}>
<VStack
spacing={2}
maxH="calc(100vh - 25em)"
overflowY="auto"
w="full"
p={0}
>
<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="wss://sip.jambonz.cloud:8443/"
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 fontWeight="">SIP Password</FormLabel>
<PasswordInput
password={[sipPassword, setSipPassword]}
placeHolder="Enter your password"
/>
</FormControl>
<FormControl id="sip_display_name">
<FormLabel>SIP Display Name (Optional)</FormLabel>
<Input
type="text"
placeholder="Display name"
value={sipDisplayName}
onChange={(e) => setSipDisplayName(e.target.value)}
/>
</FormControl>
</VStack>
<Button colorScheme="jambonz" type="submit" w="full">
Save
</Button>
<VStack w="full" alignItems="center" mt={2}>
{/* <HStack spacing={1}>
<Image src={InfoIcon} w="30px" h="30px" />
<Text fontSize="14px">Get help</Text>
</HStack> */}
{/* <Spacer /> */}
<HStack spacing={1}>
<Image src={ResetIcon} w="30px" h="30px" />
<Text fontSize="14px" onClick={resetSetting} cursor="pointer">
Reset settings
</Text>
</HStack>
</VStack>
</VStack>
</form>
);
};
export default BasicSettings;

View File

@@ -1,167 +1,26 @@
import {
Button,
FormControl,
FormLabel,
HStack,
Input,
Spacer,
VStack,
useToast,
Image,
Box,
Flex,
Text,
} 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 PasswordInput from "src/components/password-input";
import { getSettings, saveSettings } from "src/storage";
import InfoIcon from "src/imgs/icons/Info.svg";
import ResetIcon from "src/imgs/icons/Reset.svg";
import { Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
import React from "react";
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
import BasicSettings from "./basic";
import AdvancedSettings from "./advanced";
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,
colorScheme: "jambonz",
});
};
const resetSetting = () => {
saveSettings({} as AppSettings);
setSipDomain("");
setSipServerAddress("");
setSipUsername("");
setSipPassword("");
setSipDisplayName("");
setApiKey("");
};
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={2}>
<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>
<Tabs isFitted colorScheme={DEFAULT_COLOR_SCHEME}>
<TabList mb="1em" gap={1}>
<Tab>Basic</Tab>
<Tab>Advanced</Tab>
</TabList>
<FormControl id="jambonz_server_address">
<FormLabel>Jambonz Server Address</FormLabel>
<Input
type="text"
placeholder="wss://sip.jambonz.cloud:8443/"
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 fontWeight="">SIP Password</FormLabel>
<PasswordInput
password={[sipPassword, setSipPassword]}
placeHolder="Enter your password"
/>
</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>
<PasswordInput password={[apiKey, setApiKey]} />
</FormControl>
<Button colorScheme="jambonz" type="submit" w="full">
Save
</Button>
<HStack w="full">
<HStack spacing={1}>
<Image src={InfoIcon} w="30px" h="30px" />
<Text fontSize="14px">Get help</Text>
</HStack>
<Spacer />
<HStack spacing={1}>
<Image src={ResetIcon} w="30px" h="30px" />
<Text fontSize="14px" onClick={resetSetting} cursor="pointer">
Reset settings
</Text>
</HStack>
</HStack>
</VStack>
</form>
<TabPanels mt={1}>
<TabPanel p={0}>
<BasicSettings />
</TabPanel>
<TabPanel p={0}>
<AdvancedSettings />
</TabPanel>
</TabPanels>
</Tabs>
);
};

View File

@@ -184,7 +184,7 @@ function getHtmlPlugins(chunks) {
return chunks.map(
(chunk) =>
new HTMLPlugin({
title: "Jambonz Webrtc Client",
title: "Jambonz Webphone",
filename: `${chunk}.html`,
chunks: [chunk],
})