mirror of
https://github.com/jambonz/chrome-extension-dialer.git
synced 2025-12-19 04:47:45 +00:00
BIN
public/audios/call-failed.mp3
Normal file
BIN
public/audios/call-failed.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/audios/remote-party-hungup-tone.mp3
Normal file
BIN
public/audios/remote-party-hungup-tone.mp3
Normal file
Binary file not shown.
BIN
public/audios/us-busy-signal.mp3
Normal file
BIN
public/audios/us-busy-signal.mp3
Normal file
Binary file not shown.
BIN
public/audios/us-ringback.mp3
Normal file
BIN
public/audios/us-ringback.mp3
Normal file
Binary file not shown.
1
src/api/constants.ts
Normal file
1
src/api/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MSG_SOMETHING_WRONG = "Something went wrong, please try again";
|
||||
146
src/api/index.ts
Normal file
146
src/api/index.ts
Normal 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
36
src/api/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
70
src/components/menu/index.tsx
Normal file
70
src/components/menu/index.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
148
src/window/settings/advanced.tsx
Normal file
148
src/window/settings/advanced.tsx
Normal 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;
|
||||
162
src/window/settings/basic.tsx
Normal file
162
src/window/settings/basic.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user