├── .npmignore
├── .travis.yml
├── docs
└── images
│ └── softphone-interface.png
├── .editorconfig
├── .gitignore
├── .eslintrc
├── rollup.config.js
├── src
├── phoneBlocks
│ ├── Label.jsx
│ ├── search-list.jsx
│ ├── status-block.jsx
│ ├── call-queue.jsx
│ ├── SwipeCaruselBodyBlock.jsx
│ ├── SettingsBlock.jsx
│ ├── KeypadBlock.jsx
│ └── swipe-carusel-block.jsx
├── constants.js
├── CallsFlowControl.jsx
└── index.jsx
├── package.json
└── README.md
/.npmignore:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 8
4 | env:
5 | - SKIP_PREFLIGHT_CHECK=true
6 |
--------------------------------------------------------------------------------
/docs/images/softphone-interface.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chamuridis/react-softphone/HEAD/docs/images/softphone-interface.png
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | # builds
4 | build
5 | dist
6 | .rpt2_cache
7 |
8 | # misc
9 | .DS_Store
10 | .env
11 | .env.local
12 | .env.development.local
13 | .env.test.local
14 | .env.production.local
15 |
16 | npm-debug.log*
17 | yarn-debug.log*
18 | yarn-error.log*
19 | /.idea
20 | *.tgz
21 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": [
4 | "standard",
5 | "standard-react"
6 | ],
7 | "env": {
8 | "es6": true
9 | },
10 | "plugins": [
11 | "react"
12 | ],
13 | "parserOptions": {
14 | "sourceType": "module"
15 | },
16 | "rules": {
17 | // don't force es6 functions to include space before paren
18 | "space-before-function-paren": 0,
19 |
20 | // allow specifying true explicitly for boolean props
21 | "react/jsx-boolean-value": 0
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import { nodeResolve } from '@rollup/plugin-node-resolve'
2 | import commonjs from '@rollup/plugin-commonjs'
3 | import json from '@rollup/plugin-json'
4 | import esbuild from 'rollup-plugin-esbuild'
5 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'
6 | import terser from '@rollup/plugin-terser'
7 |
8 | import pkg from './package.json' with { type: 'json' }
9 |
10 | export default {
11 | input: 'src/index.jsx',
12 | external: (id) => {
13 | // Only React and ReactDOM as external - bundle everything else including ALL MUI
14 | if (id === 'react' || id === 'react-dom' || id === 'react/jsx-runtime') {
15 | return true;
16 | }
17 | // Ensure MUI packages are NOT external (should be bundled)
18 | if (id.startsWith('@mui/') || id.startsWith('@emotion/')) {
19 | return false;
20 | }
21 | return false;
22 | },
23 | output: [
24 | {
25 | file: pkg.main,
26 | format: 'cjs',
27 | sourcemap: true,
28 | exports: 'named'
29 | },
30 | {
31 | file: pkg.module,
32 | format: 'es',
33 | sourcemap: true
34 | }
35 | ],
36 | plugins: [
37 | json(),
38 | nodeResolve({
39 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
40 | preferBuiltins: false,
41 | browser: true
42 | }),
43 | commonjs({
44 | include: /node_modules/
45 | }),
46 | esbuild({
47 | target: 'es2018',
48 | jsx: 'automatic',
49 | jsxImportSource: 'react'
50 | }),
51 | terser()
52 | ]
53 | }
54 |
--------------------------------------------------------------------------------
/src/phoneBlocks/Label.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { styled, alpha } from '@mui/material/styles';
3 |
4 | const LabelRoot = styled('span')(({ theme, color }) => ({
5 | fontFamily: theme.typography.fontFamily,
6 | alignItems: 'center',
7 | borderRadius: 2,
8 | display: 'inline-flex',
9 | flexGrow: 0,
10 | whiteSpace: 'nowrap',
11 | cursor: 'default',
12 | flexShrink: 0,
13 | fontSize: theme.typography.pxToRem(12),
14 | fontWeight: theme.typography.fontWeightMedium,
15 | height: 20,
16 | justifyContent: 'center',
17 | letterSpacing: 0.5,
18 | minWidth: 20,
19 | padding: theme.spacing(0.5, 1),
20 | textTransform: 'uppercase',
21 | ...(color === 'primary' && {
22 | color: theme.palette.primary.main,
23 | backgroundColor: alpha(theme.palette.primary.main, 0.08)
24 | }),
25 | ...(color === 'secondary' && {
26 | color: theme.palette.secondary.main,
27 | backgroundColor: alpha(theme.palette.secondary.main, 0.08)
28 | }),
29 | ...(color === 'error' && {
30 | color: theme.palette.error.main,
31 | backgroundColor: alpha(theme.palette.error.main, 0.08)
32 | }),
33 | ...(color === 'success' && {
34 | color: theme.palette.success.main,
35 | backgroundColor: alpha(theme.palette.success.main, 0.08)
36 | }),
37 | ...(color === 'warning' && {
38 | color: theme.palette.warning.main,
39 | backgroundColor: alpha(theme.palette.warning.main, 0.08)
40 | })
41 | }));
42 |
43 | function Label({ className, color = 'secondary', children, style, ...rest }) {
44 | return (
45 |
51 | {children}
52 |
53 | );
54 | }
55 |
56 | export default Label;
57 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-softphone",
3 | "version": "3.1.1",
4 | "description": "Webrtc Softphone React",
5 | "type": "module",
6 | "main": "dist/index.js",
7 | "module": "dist/index.es.js",
8 | "jsnext:main": "dist/index.es.js",
9 | "engines": {
10 | "node": ">=18",
11 | "npm": ">=9"
12 | },
13 | "exports": {
14 | ".": {
15 | "import": "./dist/index.es.js",
16 | "require": "./dist/index.js"
17 | }
18 | },
19 | "files": [
20 | "dist",
21 | "src"
22 | ],
23 | "scripts": {
24 | "build": "rollup -c",
25 | "dev": "rollup -c --watch",
26 | "prepack": "npm run build",
27 | "release": "npx np",
28 | "test": ""
29 | },
30 | "repository": {
31 | "type": "git",
32 | "url": "git+https://github.com/chamuridis/react-softphone.git"
33 | },
34 | "keywords": [
35 | "React",
36 | "WebRTC",
37 | "Softphone",
38 | "VoIP",
39 | "SIP",
40 | "JsSIP",
41 | "Phone",
42 | "Calling"
43 | ],
44 | "author": "Dionis",
45 | "license": "ISC",
46 | "bugs": {
47 | "url": "https://github.com/chamuridis/react-softphone/issues"
48 | },
49 | "homepage": "https://github.com/chamuridis/react-softphone#readme",
50 | "dependencies": {
51 | "@emotion/react": "^11.11.0",
52 | "@emotion/styled": "^11.11.0",
53 | "@mui/icons-material": "^5.15.0",
54 | "@mui/material": "^5.15.0",
55 | "@mui/system": "^5.15.0",
56 | "@phosphor-icons/react": "^2.0.0",
57 | "jssip": "^3.10.0",
58 | "lodash": "^4.17.21",
59 | "luxon": "^3.4.4"
60 | },
61 | "peerDependencies": {
62 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
63 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
64 | },
65 | "sideEffects": false,
66 | "funding": {
67 | "type": "github",
68 | "url": "https://github.com/sponsors/chamuridis"
69 | },
70 | "devDependencies": {
71 | "@rollup/plugin-commonjs": "^26.0.0",
72 | "@rollup/plugin-json": "^6.1.0",
73 | "@rollup/plugin-node-resolve": "^15.0.0",
74 | "@rollup/plugin-terser": "^0.4.0",
75 | "esbuild": ">=0.25.0",
76 | "rollup": "^4.0.0",
77 | "rollup-plugin-esbuild": "^6.0.0",
78 | "rollup-plugin-peer-deps-external": "^2.2.0"
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/phoneBlocks/search-list.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Popover,
4 | TextField,
5 | Autocomplete
6 | } from '@mui/material';
7 | import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
8 | import { green, red } from '@mui/material/colors';
9 |
10 | function SearchList({
11 | asteriskAccounts = [],
12 | onClickList,
13 | ariaDescribedby,
14 | anchorEl,
15 | setAnchorEl
16 | }) {
17 | const open = Boolean(anchorEl);
18 | const id = open ? `${ariaDescribedby}` : undefined;
19 | const handleClose = () => setAnchorEl(null);
20 | const handleClick = (value) => {
21 | onClickList(value);
22 | setAnchorEl(null);
23 | };
24 |
25 |
26 | return (
27 | <>
28 | { open ? (
29 |
37 |
38 | option?.accountId || ''}
42 | style={{ width: 300 }}
43 | renderOption={(props, option) => {
44 | const { key, ...otherProps } = props;
45 | return (
46 |
47 |
48 | {option.accountId}
49 | {option.label}
50 | {
51 | Number(option.online) === 1 ? (
52 |
56 | ) : (
57 |
61 | )
62 | }
63 |
64 |
65 | );
66 | }}
67 | renderInput={(params) => }
68 | onChange={(event, value) => {
69 | if (value) {
70 | handleClick(value.accountId);
71 | }
72 | }}
73 | />
74 |
75 | ) : null }
76 | >
77 | );
78 | }
79 | export default SearchList;
80 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | // Audio file paths
2 | export const AUDIO_PATHS = {
3 | RINGING: '/sound/ringing.ogg',
4 | RINGBACK: '/sound/ringback.ogg'
5 | };
6 |
7 | // Notification settings
8 | export const NOTIFICATION_DEFAULTS = {
9 | TITLE: 'Incoming Call',
10 | ICON: 'data:image/svg+xml;base64,' + btoa('')
11 | };
12 |
13 | // Call states
14 | export const CALL_STATES = {
15 | READY: 'Ready',
16 | RINGING: 'Ringing',
17 | ANSWERED: 'Answered',
18 | TRANSFERRING: 'Transferring...',
19 | ATTENDED_TRANSFER: 'Attended Transfer',
20 | ON_HOLD: 'On Hold',
21 | IN_TRANSFER: 'In Transfer'
22 | };
23 |
24 | // Connection states
25 | export const CONNECTION_STATES = {
26 | ONLINE: 'Online',
27 | OFFLINE: 'Offline',
28 | CONNECTING: 'Connecting',
29 | DISCONNECTING: 'Disconnecting',
30 | CONNECTED: 'Connected',
31 | DISCONNECTED: 'Disconnected'
32 | };
33 |
34 | // Environment detection utilities
35 | export const isBrowser = () => {
36 | return typeof window !== 'undefined' && typeof window.document !== 'undefined';
37 | };
38 |
39 | export const isReactNative = () => {
40 | return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
41 | };
42 |
43 | export const hasNotificationAPI = () => {
44 | return isBrowser() && 'Notification' in window;
45 | };
46 |
47 | // Debug logging utility
48 | export const debugLog = (message, ...args) => {
49 | if (isBrowser() && window.__SOFTPHONE_DEBUG__) {
50 | console.log(`[SoftPhone] ${message}`, ...args);
51 | }
52 | };
53 |
54 | export const debugError = (message, ...args) => {
55 | if (isBrowser() && window.__SOFTPHONE_DEBUG__) {
56 | console.error(`[SoftPhone] ${message}`, ...args);
57 | }
58 | };
59 |
60 | export const debugWarn = (message, ...args) => {
61 | if (isBrowser() && window.__SOFTPHONE_DEBUG__) {
62 | console.warn(`[SoftPhone] ${message}`, ...args);
63 | }
64 | };
65 |
--------------------------------------------------------------------------------
/src/phoneBlocks/status-block.jsx:
--------------------------------------------------------------------------------
1 | import { Typography, Box, Chip, CircularProgress } from '@mui/material';
2 | import React from 'react';
3 | import { styled } from '@mui/material/styles';
4 | import {
5 | WifiTethering as OnlineIcon,
6 | WifiTetheringOff as OfflineIcon,
7 | Loop as ConnectingIcon
8 | } from '@mui/icons-material';
9 |
10 | const Root = styled('div')(({ theme: _theme }) => ({
11 | padding: _theme.spacing(1),
12 | backgroundColor: _theme.palette.background.paper,
13 | borderTop: `1px solid ${_theme.palette.divider}`
14 | }));
15 |
16 | const StatusContainer = styled(Box)(({ theme: _theme }) => ({
17 | display: 'flex',
18 | flexDirection: 'column',
19 | width: '100%',
20 | gap: _theme.spacing(1)
21 | }));
22 |
23 | const StatusRow = styled(Box)(({ theme: _theme }) => ({
24 | display: 'flex',
25 | justifyContent: 'space-between',
26 | alignItems: 'center',
27 | width: '100%'
28 | }));
29 |
30 | const StatusLabel = styled(Typography)(({ theme: _theme }) => ({
31 | fontWeight: 500,
32 | color: _theme.palette.text.secondary,
33 | fontSize: '0.75rem'
34 | }));
35 |
36 | const OnlineChip = styled(Chip)(({ theme: _theme }) => ({
37 | backgroundColor: '#e8f5e9',
38 | color: '#2e7d32',
39 | fontWeight: 500,
40 | height: '24px',
41 | fontSize: '0.7rem',
42 | '& .MuiChip-icon': {
43 | color: '#2e7d32',
44 | fontSize: '0.9rem'
45 | }
46 | }));
47 |
48 | const OfflineChip = styled(Chip)(({ theme: _theme }) => ({
49 | backgroundColor: '#ffebee',
50 | color: '#c62828',
51 | fontWeight: 500,
52 | height: '24px',
53 | fontSize: '0.7rem',
54 | '& .MuiChip-icon': {
55 | color: '#c62828',
56 | fontSize: '0.9rem'
57 | }
58 | }));
59 |
60 | const ConnectingChip = styled(Chip)(({ theme: _theme }) => ({
61 | backgroundColor: '#e3f2fd',
62 | color: '#1565c0',
63 | fontWeight: 500,
64 | height: '24px',
65 | fontSize: '0.7rem',
66 | '& .MuiChip-icon': {
67 | color: '#1565c0',
68 | fontSize: '0.9rem'
69 | }
70 | }));
71 |
72 | function StatusBlock({ connectingPhone, connectedPhone }) {
73 |
74 | // Check if internet is available
75 | const [internetConnected, setInternetConnected] = React.useState(navigator.onLine);
76 |
77 | // Monitor internet connection status
78 | React.useEffect(() => {
79 | const handleOnline = () => setInternetConnected(true);
80 | const handleOffline = () => setInternetConnected(false);
81 |
82 | globalThis.addEventListener('online', handleOnline);
83 | globalThis.addEventListener('offline', handleOffline);
84 |
85 | return () => {
86 | globalThis.removeEventListener('online', handleOnline);
87 | globalThis.removeEventListener('offline', handleOffline);
88 | };
89 | }, []);
90 |
91 | // Get the softphone connection status chip
92 | const getSoftphoneStatusChip = () => {
93 | // First check if internet is connected - if not, softphone must be offline too
94 | if (!internetConnected) {
95 | return } label="Offline" />;
96 | }
97 |
98 | // Only check actual softphone status if internet is available
99 | if (connectingPhone) {
100 | return connectedPhone
101 | ? } label="Disconnecting" />
102 | : } label="Connecting" />;
103 | }
104 |
105 | return connectedPhone
106 | ? } label="Online" />
107 | : } label="Offline" />;
108 | };
109 |
110 | // Get the internet connection status chip
111 | const getInternetStatusChip = () => {
112 | return internetConnected
113 | ? } label="Online" />
114 | : } label="Offline" />;
115 | };
116 |
117 | return (
118 |
119 |
120 |
121 | Internet
122 | {getInternetStatusChip()}
123 |
124 |
125 | Softphone Connection
126 | {getSoftphoneStatusChip()}
127 |
128 |
129 |
130 | );
131 | }
132 |
133 | export default StatusBlock;
134 |
--------------------------------------------------------------------------------
/src/phoneBlocks/call-queue.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Grid, Typography, Box, Paper, Fab
3 | } from '@mui/material';
4 | import React from 'react';
5 | import { styled } from '@mui/material/styles';
6 | import { Call, CallEnd } from '@mui/icons-material';
7 |
8 | const Root = styled('div')({
9 | alignItems: 'center',
10 | width: '100%'
11 | });
12 |
13 | // Define keyframes for the shake animation
14 | const shakeAnimation = {
15 | '@keyframes shake': {
16 | '0%': { transform: 'translateX(0)' },
17 | '10%': { transform: 'translateX(-3px) rotate(-1deg)' },
18 | '20%': { transform: 'translateX(3px) rotate(1deg)' },
19 | '30%': { transform: 'translateX(-3px) rotate(-1deg)' },
20 | '40%': { transform: 'translateX(3px) rotate(1deg)' },
21 | '50%': { transform: 'translateX(-2px) rotate(-0.5deg)' },
22 | '60%': { transform: 'translateX(2px) rotate(0.5deg)' },
23 | '70%': { transform: 'translateX(-1px) rotate(-0.25deg)' },
24 | '80%': { transform: 'translateX(1px) rotate(0.25deg)' },
25 | '90%': { transform: 'translateX(-1px) rotate(-0.25deg)' },
26 | '100%': { transform: 'translateX(0)' }
27 | }
28 | };
29 |
30 | const AnswerButton = styled(Fab)(({ theme }) => ({
31 | color: theme.palette.common.white,
32 | backgroundColor: theme.palette.success.main,
33 | '&:hover': {
34 | backgroundColor: theme.palette.success.dark
35 | },
36 | fontSize: 9,
37 | alignItems: 'center',
38 | animation: 'shake 1.5s infinite',
39 | '@keyframes shake': shakeAnimation['@keyframes shake']
40 | }));
41 |
42 | const RejectButton = styled(Fab)(({ theme }) => ({
43 | color: theme.palette.common.white,
44 | backgroundColor: theme.palette.error.main,
45 | '&:hover': {
46 | backgroundColor: theme.palette.error.dark
47 | },
48 | fontSize: 9,
49 | alignItems: 'center',
50 | animation: 'shake 1.5s infinite',
51 | '@keyframes shake': shakeAnimation['@keyframes shake']
52 | }));
53 |
54 | const Caller = styled(Typography)({
55 | fontWeight: 500
56 | });
57 |
58 | const CallerSmall = styled(Typography)({
59 | fontWeight: 400
60 | });
61 |
62 | const CallItem = styled(Paper)(({ theme }) => ({
63 | margin: theme.spacing(1, 0, 2),
64 | padding: theme.spacing(2),
65 | borderRadius: theme.shape.borderRadius * 2,
66 | boxShadow: theme.palette.mode === 'dark'
67 | ? '0 4px 8px rgba(0, 0, 0, 0.3)'
68 | : '0 2px 6px rgba(0, 0, 0, 0.1)'
69 | }));
70 |
71 | function CallQueue({ calls, handleAnswer, handleReject }) {
72 | return (
73 |
74 | {calls.map((call) => {
75 | const parsedCaller = call.callNumber.split('-');
76 | return (
77 |
78 |
79 | {parsedCaller[0] && (
80 |
81 |
82 | Caller:
83 | {parsedCaller[0]}
84 |
85 |
86 |
87 | )}
88 |
89 |
90 | {parsedCaller[1] && (
91 |
92 |
93 | Jurisdiction:
94 |
95 | {parsedCaller[1]}
96 |
97 |
98 |
99 | )}
100 |
101 | {parsedCaller[2] && (
102 |
103 |
104 | Company Number:
105 |
106 | {parsedCaller[2]}
107 |
108 |
109 |
110 | )}
111 |
112 |
113 |
114 |
115 |
116 |
126 |
127 |
128 |
129 |
130 |
140 |
141 |
142 |
143 |
144 |
145 | );
146 | })}
147 |
148 | );
149 | }
150 |
151 | export default CallQueue;
152 |
--------------------------------------------------------------------------------
/src/phoneBlocks/SwipeCaruselBodyBlock.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import {
3 | Box,
4 | Tab,
5 | Tabs,
6 | Typography,
7 | List,
8 | Paper,
9 | Divider,
10 | AppBar
11 | } from '@mui/material';
12 | import { styled } from '@mui/material/styles';
13 | import {
14 | AccessTime,
15 | CallMade as CallMadeIcon,
16 | CallReceived as CallReceivedIcon,
17 | Settings as SettingsIcon,
18 | History as HistoryIcon
19 | } from '@mui/icons-material';
20 | import { DateTime } from 'luxon';
21 |
22 | import SettingsBlock from './SettingsBlock';
23 |
24 | function TabPanel(props) {
25 | const {
26 | children, value, index, sx, ...other
27 | } = props;
28 |
29 | return (
30 |
39 | {value === index && {children}}
40 |
41 | );
42 | }
43 |
44 | function a11yProps(index) {
45 | return {
46 | id: `full-width-tab-${index}`,
47 | 'aria-controls': `full-width-tabpanel-${index}`
48 | };
49 | }
50 |
51 | const StyledTab = styled(Tab)(({ theme }) => ({
52 | textTransform: 'none',
53 | minWidth: '25%',
54 | marginRight: 'auto',
55 | fontWeight: 500,
56 | paddingTop: theme.spacing(0.5),
57 | paddingBottom: theme.spacing(0.5),
58 | color: theme.palette.text.secondary,
59 | '&.Mui-selected': {
60 | color: theme.palette.primary.main,
61 | fontWeight: 600
62 | }
63 | }));
64 |
65 | const StyledAppBar = styled(AppBar)(({ theme }) => ({
66 | borderRadius: 0,
67 | boxShadow: 'none',
68 | backgroundColor: theme.palette.background.paper,
69 | borderBottom: `1px solid ${theme.palette.divider}`
70 | }));
71 |
72 | const ListRoot = styled(List)(({ theme }) => ({
73 | width: '100%',
74 | maxHeight: '300px',
75 | overflow: 'auto',
76 | backgroundColor: theme.palette.background.paper,
77 | marginTop: theme.spacing(1)
78 | }));
79 |
80 | const CallLogPaper = styled(Paper)(({ theme }) => ({
81 | margin: theme.spacing(1),
82 | padding: theme.spacing(1.5),
83 | borderRadius: theme.shape.borderRadius * 1.5,
84 | overflow: 'hidden',
85 | boxShadow: theme.shadows[1],
86 | backgroundColor: theme.palette.background.paper,
87 | border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)'}`
88 | }));
89 |
90 | const ListSectionStyled = styled('li')({
91 | width: '100%',
92 | listStyle: 'none'
93 | });
94 |
95 | const UlStyled = styled('ul')({
96 | padding: 0
97 | });
98 |
99 | const TabStyled = styled('div')(({ theme }) => ({
100 | padding: theme.spacing(1),
101 | overflow: 'visible',
102 | flexGrow: 1,
103 | display: 'flex',
104 | flexDirection: 'column'
105 | }));
106 |
107 | const EmptyCallsContainer = styled(Box)(({ theme }) => ({
108 | display: 'flex',
109 | flexDirection: 'column',
110 | alignItems: 'center',
111 | justifyContent: 'center',
112 | minHeight: '150px',
113 | padding: theme.spacing(3),
114 | color: theme.palette.text.secondary,
115 | gap: theme.spacing(1.5),
116 | backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.02)',
117 | borderRadius: theme.shape.borderRadius * 1.5,
118 | '& svg': {
119 | fontSize: '2.5rem',
120 | opacity: 0.6,
121 | marginBottom: theme.spacing(1)
122 | }
123 | }));
124 |
125 | function SwipeCaruselBodyBlock({
126 | localStatePhone,
127 | handleConnectPhone,
128 | handleSettingsSlider,
129 | handleConnectOnStart,
130 | handleNotifications,
131 | handleDarkMode,
132 | calls,
133 | timelocale
134 | }) {
135 | const [value, setValue] = useState(0);
136 |
137 | const handleChange = (event, newValue) => {
138 | setValue(newValue);
139 | };
140 |
141 | return (
142 |
143 |
144 |
152 | }
154 | label="Settings"
155 | {...a11yProps(0)}
156 | />
157 | }
159 | label="History"
160 | {...a11yProps(1)}
161 | />
162 |
163 |
164 |
165 |
166 | {/* Settings Block */}
167 |
168 |
176 |
177 |
178 |
179 |
180 | {calls.length === 0 ? (
181 |
182 |
183 | No call history
184 | Your recent calls will appear here
185 |
186 | ) : (
187 | }>
188 | {calls.map(({
189 | sessionId, direction, number, time, status
190 | }) => (
191 |
192 |
193 |
194 |
200 |
201 |
210 | {number}
211 | {direction === 'outgoing' ? (
212 |
213 | ) : (
214 |
215 | )}
216 |
217 |
218 |
219 | {DateTime.fromISO(time.toISOString())
220 | .setZone(timelocale)
221 | .toFormat('dd MMM, HH:mm')}
222 |
223 |
224 |
225 |
226 |
227 |
228 | ))}
229 |
230 | )}
231 |
232 |
233 |
234 | );
235 | }
236 |
237 | export default SwipeCaruselBodyBlock;
238 |
--------------------------------------------------------------------------------
/src/phoneBlocks/SettingsBlock.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { styled } from '@mui/material/styles';
3 | import {
4 | VolumeUp,
5 | VolumeOff,
6 | NotificationsActive,
7 | NotificationsOff,
8 | PhoneEnabled,
9 | PhoneDisabled,
10 | DarkMode,
11 | LightMode,
12 | Palette,
13 | CallEnd,
14 | Call
15 | } from '@mui/icons-material';
16 | import {
17 | Grid,
18 | FormControl,
19 | FormGroup,
20 | FormControlLabel,
21 | Slider,
22 | Switch,
23 | LinearProgress,
24 | Typography,
25 | Box,
26 | Paper,
27 | Divider,
28 | IconButton,
29 | Button
30 | } from '@mui/material';
31 |
32 | const Root = styled('div')(({ theme }) => ({
33 | padding: theme.spacing(1),
34 | height: '100%',
35 | display: 'flex',
36 | flexDirection: 'column'
37 | }));
38 |
39 | const SettingsContainer = styled(Box)(({ theme }) => ({
40 | display: 'flex',
41 | flexDirection: 'column',
42 | gap: theme.spacing(1.5),
43 | flexGrow: 1
44 | }));
45 |
46 | const SettingsCard = styled(Paper)(({ theme }) => ({
47 | borderRadius: theme.shape.borderRadius * 2,
48 | padding: theme.spacing(2),
49 | boxShadow: theme.palette.mode === 'dark'
50 | ? '0 4px 8px rgba(0, 0, 0, 0.3)'
51 | : '0 2px 6px rgba(0, 0, 0, 0.1)',
52 | marginBottom: theme.spacing(1)
53 | }));
54 |
55 | const SettingHeader = styled(Typography)(({ theme }) => ({
56 | fontWeight: 500,
57 | marginBottom: theme.spacing(0.5),
58 | color: theme.palette.text.primary,
59 | fontSize: '0.8rem'
60 | }));
61 |
62 | const SliderContainer = styled(Box)(({ theme }) => ({
63 | display: 'flex',
64 | alignItems: 'center',
65 | gap: theme.spacing(1),
66 | flexGrow: 1,
67 | width: '100%'
68 | }));
69 |
70 | const SliderIcons = styled(Box)(({ theme }) => ({
71 | color: theme.palette.text.secondary,
72 | minWidth: '24px'
73 | }));
74 |
75 | const StyledSlider = styled(Slider)(({ theme }) => ({
76 | width: '100%',
77 | '& .MuiSlider-track': {
78 | height: 4,
79 | },
80 | '& .MuiSlider-thumb': {
81 | width: 16,
82 | height: 16,
83 | '&:hover, &.Mui-focusVisible': {
84 | boxShadow: `0px 0px 0px 8px ${theme.palette.primary.main}20`,
85 | },
86 | },
87 | '& .MuiSlider-valueLabel': {
88 | fontSize: '0.75rem',
89 | },
90 | '& .MuiSlider-mark': {
91 | width: 2,
92 | height: 2,
93 | borderRadius: 1,
94 | }
95 | }));
96 |
97 | const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
98 | margin: 0,
99 | width: '100%',
100 | justifyContent: 'space-between',
101 | '& .MuiTypography-root': {
102 | fontWeight: 500,
103 | fontSize: '0.8rem',
104 | color: theme.palette.text.primary
105 | },
106 | '& .MuiSwitch-root': {
107 | marginLeft: theme.spacing(1)
108 | }
109 | }));
110 |
111 | const Form = styled(FormControl)({
112 | width: '100%'
113 | });
114 |
115 | const ConnectButton = styled(Button)(({ theme, isConnected }) => ({
116 | marginTop: theme.spacing(1),
117 | backgroundColor: isConnected ? theme.palette.error.main : theme.palette.success.main,
118 | color: theme.palette.common.white,
119 | '&:hover': {
120 | backgroundColor: isConnected ? theme.palette.error.dark : theme.palette.success.dark,
121 | },
122 | fontSize: '0.75rem',
123 | padding: theme.spacing(0.5, 1)
124 | }));
125 |
126 | function SettingsBlock({
127 | localStatePhone,
128 | handleConnectPhone,
129 | handleSettingsSlider,
130 | handleConnectOnStart,
131 | handleNotifications,
132 | handleDarkMode
133 | }) {
134 | return (
135 |
136 |
137 |
138 | Connection
139 |
140 |
183 |
184 |
185 |
186 | Notifications
187 |
188 |
210 |
211 |
212 |
213 | Volume
214 |
215 |
216 | {/* Call Audio Volume Control */}
217 |
218 |
219 | Call Audio
220 |
221 |
222 |
223 | {localStatePhone.callVolume === 0 ? : }
224 |
225 | handleSettingsSlider('callVolume', val)}
231 | aria-labelledby="call-volume-slider"
232 | size="small"
233 | marks={[
234 | {
235 | value: 0,
236 | label: '0%',
237 | },
238 | {
239 | value: 0.5,
240 | label: '50%',
241 | },
242 | {
243 | value: 1,
244 | label: '100%',
245 | },
246 | ]}
247 | />
248 |
249 |
250 |
251 | {/* Ringtone Volume Control */}
252 |
253 |
254 | Ringtone
255 |
256 |
257 |
258 | {localStatePhone.ringVolume === 0 ? : }
259 |
260 | handleSettingsSlider('ringVolume', val)}
266 | aria-labelledby="ringtone-volume-slider"
267 | size="small"
268 | marks={[
269 | {
270 | value: 0,
271 | label: '0%',
272 | },
273 | {
274 | value: 0.5,
275 | label: '50%',
276 | },
277 | {
278 | value: 1,
279 | label: '100%',
280 | },
281 | ]}
282 | />
283 |
284 |
285 |
286 |
287 |
288 |
289 | );
290 | }
291 |
292 | export default SettingsBlock;
293 |
--------------------------------------------------------------------------------
/src/CallsFlowControl.jsx:
--------------------------------------------------------------------------------
1 | import { UA, debug } from 'jssip';
2 | import _ from 'lodash';
3 | import { debugLog, debugError, debugWarn } from './constants';
4 |
5 | function CallsFlowControl() {
6 | this.onUserAgentAction = () => {};
7 |
8 | this.notify = (message) => {
9 | this.onCallActionConnection('notify', message);
10 | };
11 | this.tmpEvent = () => {
12 | console.log(this.activeCall);
13 | console.log(this.callsQueue);
14 | console.log(this.holdCallsQueue);
15 | };
16 | this.onCallActionConnection = () => {};
17 | this.engineEvent = () => {};
18 | this.setMicMuted = () => {
19 | if (this.micMuted && this.activeCall) {
20 | this.activeCall.unmute();
21 | this.micMuted = false;
22 | this.onCallActionConnection('unmute', this.activeCall.id);
23 | } else if (!this.micMuted && this.activeCall) {
24 | this.micMuted = true;
25 | this.activeCall.mute();
26 | this.onCallActionConnection('mute', this.activeCall.id);
27 | }
28 | };
29 | this.hold = (sessionId) => {
30 | // If there is an active call with id that is requested then fire hold
31 | if (this.activeCall.id === sessionId) {
32 | this.activeCall.hold();
33 | }
34 | };
35 | this.unhold = (sessionId) => {
36 | // If we dont have active call then unhold the the call with requested id
37 | if (!this.activeCall) {
38 | // Find the Requested call in hold calls array
39 | const toUnhold = _.find(this.holdCallsQueue, { id: sessionId });
40 | // If we found the call in hold calls array the fire unhold function
41 | if (toUnhold) {
42 | toUnhold.unhold();
43 | }
44 | } else {
45 | debugLog('Please exit from all active calls to unhold');
46 | this.notify('Please exit from all active calls to unhold');
47 |
48 | }
49 | };
50 | this.micMuted = false;
51 | this.activeCall = null;
52 | this.activeChanel = null;
53 | this.callsQueue = [];
54 | this.holdCallsQueue = [];
55 | this.player = {};
56 | this.ringer = null;
57 | this.connectedPhone = null;
58 | this.config = {};
59 | this.initiated = false;
60 | this.playRing = () => {
61 | if (this.ringer && this.ringer.current) {
62 | try {
63 | this.ringer.current.currentTime = 0;
64 | this.ringer.current.play().catch((err) => console.error('Ringtone play error:', err));
65 | } catch (err) {
66 | console.error('Failed to play ringtone:', err);
67 | }
68 | }
69 | };
70 | this.stopRing = () => {
71 | this.ringer.current.currentTime = 0;
72 | this.ringer.current.pause();
73 | };
74 |
75 | this.startRingback = () => {
76 | if (this.ringbackTone) {
77 | try {
78 | this.ringbackTone.currentTime = 0; // Reset to the start
79 | this.ringbackTone.play().catch((err) => console.error('Ringback tone play error:', err));
80 | } catch (e) {
81 | console.error('Failed to play ringback tone:', e);
82 | }
83 | }
84 | };
85 |
86 | this.stopRingback = () => {
87 | if (this.ringbackTone) {
88 | try {
89 | this.ringbackTone.pause();
90 | this.ringbackTone.currentTime = 0; // Reset to the start for future calls
91 | } catch (e) {
92 | console.error('Failed to stop ringback tone:', e);
93 | }
94 | }
95 | };
96 |
97 | this.removeCallFromQueue = (callId) => {
98 | _.remove(this.callsQueue, (calls) => calls.id === callId);
99 | };
100 | this.addCallToHoldQueue = (callId) => {
101 | if (this.activeCall.id === callId) {
102 | this.holdCallsQueue.push(this.activeCall);
103 | }
104 | };
105 | this.removeCallFromActiveCall = (callId) => {
106 | if (this.activeCall && callId === this.activeCall.id) {
107 | this.activeCall = null;
108 | }
109 | };
110 | this.removeCallFromHoldQueue = (callId) => {
111 | _.remove(this.holdCallsQueue, (calls) => calls.id === callId);
112 | };
113 | this.connectAudio = () => {
114 | this.activeCall.connection.addEventListener('addstream', (event) => {
115 | this.player.current.srcObject = event.stream;
116 | });
117 | };
118 |
119 | this.sessionEvent = (type, data, cause, callId) => {
120 | // console.log(`Session: ${type}`);
121 | // console.log('Data: ', data);
122 | // console.log('callid: ', callId);
123 |
124 | switch (type) {
125 | case 'terminated':
126 | // this.endCall(data, cause);
127 | break;
128 | case 'progress':
129 | if (data.originator === 'remote') {
130 | // Play ringback tone for outgoing calls only
131 | if (data.response.status_code === 180) {
132 | this.startRingback();
133 | }
134 | if (data.response.status_code === 183) {
135 | this.stopRingback();
136 | }
137 | } else {
138 | // Do nothing for incoming calls
139 | console.log('Progress event for incoming call, ignoring...');
140 | }
141 | break;
142 | case 'accepted':
143 | // this.startCall(data);
144 | break;
145 | case 'reinvite':
146 | this.onCallActionConnection('reinvite', callId, data);
147 | break;
148 | case 'hold':
149 | this.onCallActionConnection('hold', callId);
150 | this.addCallToHoldQueue(callId);
151 | this.removeCallFromActiveCall(callId);
152 | break;
153 | case 'unhold':
154 | this.onCallActionConnection('unhold', callId);
155 | this.activeCall = _.find(this.holdCallsQueue, { id: callId });
156 | this.removeCallFromHoldQueue(callId);
157 | break;
158 | case 'dtmf':
159 | break;
160 | case 'muted':
161 | this.onCallActionConnection('muted', callId);
162 | break;
163 | case 'unmuted':
164 | break;
165 | case 'confirmed':
166 | this.stopRingback();
167 | if (!this.activeCall) {
168 | this.activeCall = _.find(this.callsQueue, { id: callId });
169 | }
170 | this.removeCallFromQueue(callId);
171 | this.onCallActionConnection('callAccepted', callId, this.activeCall);
172 | break;
173 | case 'connecting':
174 | break;
175 | case 'ended':
176 | this.onCallActionConnection('callEnded', callId);
177 | this.removeCallFromQueue(callId);
178 | this.removeCallFromActiveCall(callId);
179 | this.removeCallFromHoldQueue(callId);
180 | if (this.callsQueue.length === 0) {
181 | this.stopRing();
182 | }
183 | break;
184 | case 'failed':
185 | this.stopRingback();
186 | this.onCallActionConnection('callEnded', callId);
187 | this.removeCallFromQueue(callId);
188 | this.removeCallFromActiveCall(callId);
189 | if (this.callsQueue.length === 0) {
190 | this.stopRing();
191 | }
192 | break;
193 | default:
194 | // console.warn(`Unhandled event: ${type}`, { data, cause, callId });
195 | break;
196 | }
197 | };
198 |
199 | this.handleNewRTCSession = (rtcPayload) => {
200 | const { session: call } = rtcPayload;
201 | if (call.direction === 'incoming') {
202 | this.callsQueue.push(call);
203 | this.onCallActionConnection('incomingCall', call);
204 | if (!this.activeCall) {
205 | this.playRing();
206 | }
207 | } else {
208 | this.activeCall = call;
209 | this.onCallActionConnection('outgoingCall', call);
210 | this.connectAudio();
211 | }
212 | const defaultCallEventsToHandle = [
213 | 'peerconnection',
214 | 'connecting',
215 | 'sending',
216 | 'progress',
217 | 'accepted',
218 | 'newDTMF',
219 | 'newInfo',
220 | 'hold',
221 | 'unhold',
222 | 'muted',
223 | 'unmuted',
224 | 'reinvite',
225 | 'update',
226 | 'refer',
227 | 'replaces',
228 | 'sdp',
229 | 'icecandidate',
230 | 'getusermediafailed',
231 | 'ended',
232 | 'failed',
233 | 'connecting',
234 | 'confirmed'
235 | ];
236 | _.forEach(defaultCallEventsToHandle, (eventType) => {
237 | call.on(eventType, (data, cause) => {
238 | this.sessionEvent(eventType, data, cause, call.id);
239 | });
240 | });
241 | };
242 |
243 | this.validateConfig = () => {
244 | if (!this.config.domain) {
245 | console.warn('Config error: Missing domain');
246 | }
247 | };
248 | this.init = () => {
249 | try {
250 | this.validateConfig();
251 | this.phone = new UA(this.config);
252 | this.phone.on('newRTCSession', this.handleNewRTCSession.bind(this));
253 | const binds = [
254 | 'connected',
255 | 'disconnected',
256 | 'registered',
257 | 'unregistered',
258 | 'registrationFailed',
259 | 'invite',
260 | 'message',
261 | 'connecting'
262 | ];
263 | _.forEach(binds, (value) => {
264 | this.phone.on(value, (e) => {
265 | this.engineEvent(value, e);
266 | });
267 | });
268 | this.initiated = true;
269 | } catch (e) {
270 | console.log(e);
271 | }
272 | };
273 |
274 | this.call = (to) => {
275 | if (!this.connectedPhone) {
276 | this.notify('Please connect to VoIP server first');
277 | console.log('User agent not registered yet');
278 | return;
279 | }
280 | if (this.activeCall) {
281 | this.notify('Active call already exists');
282 | console.log('Already has active call');
283 | return;
284 | }
285 | this.phone.call(`sip:${to}@${this.config.domain}`, {
286 | extraHeaders: ['First: first', 'Second: second'],
287 | RTCConstraints: {
288 | optional: [{ DtlsSrtpKeyAgreement: 'true' }]
289 | },
290 | mediaConstraints: {
291 | audio: true
292 | },
293 | sessionTimersExpires: 600
294 | });
295 | };
296 |
297 | this.answer = (sessionId) => {
298 | if (this.activeCall) {
299 | console.log('Already has active call');
300 | return;
301 | }
302 | try {
303 | this.stopRing();
304 | this.activeCall = _.find(this.callsQueue, { id: sessionId });
305 | if (this.activeCall) {
306 | this.activeCall.customPayload = this.activeChanel.id;
307 | this.activeCall.answer({
308 | mediaConstraints: { audio: true },
309 | });
310 | this.connectAudio();
311 | }
312 | } catch (err) {
313 | console.error('Error answering call:', err);
314 | this.notify('Error answering call');
315 | }
316 | };
317 |
318 | this.hungup = (e) => {
319 | try {
320 | this.phone._sessions[e].terminate();
321 | } catch (s) {
322 | console.log(s);
323 | console.log('Call already terminated');
324 | }
325 | };
326 |
327 | this.start = () => {
328 | if (!this.initiated) {
329 | this.notify('Please initialize phone before connecting');
330 | console.log('Please call .init() before connect');
331 | return;
332 | }
333 |
334 | if (this.config.debug) {
335 | debug.enable('JsSIP:*');
336 | } else {
337 | debug.disable();
338 | }
339 | this.phone.start();
340 | };
341 |
342 | this.stop = () => {
343 | this.phone.stop();
344 | };
345 | }
346 |
347 | export default CallsFlowControl;
348 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React SoftPhone
2 |
3 | [](https://www.npmjs.com/package/react-softphone)
4 |
5 | A modern WebRTC softphone component for React applications with all dependencies bundled and zero translation dependencies.
6 |
7 | ## 📱 Interface Preview
8 |
9 | 
10 |
11 | *Modern, clean interface with call controls, settings, and multi-channel support*
12 |
13 | ## ✨ Features
14 |
15 | - 🚀 **Self-Contained** - All MUI dependencies bundled, no additional installs needed
16 | - 📦 **Simple Installation** - Just `npm install react-softphone` and you're ready
17 | - 🎯 **Material Design** - Beautiful UI with Material-UI components included
18 | - 📱 **WebRTC Ready** - Built on JsSIP for reliable VoIP calls
19 | - ⚛️ **Modern React** - Uses hooks and modern React patterns
20 | - 🎨 **Built-in Launcher** - Optional floating launcher button
21 | - 📞 **Call Management** - Hold, transfer, conference, and attended transfer support
22 |
23 | ## 📦 Installation
24 |
25 | ```bash
26 | npm install react-softphone
27 | ```
28 |
29 | **That's it!** All MUI dependencies are bundled - no additional packages needed.
30 |
31 |
32 | ## 🚀 Step-by-Step Setup Guide
33 |
34 | Follow this complete tutorial to get React Softphone working in your app:
35 |
36 | ### Step 1: Create a New React App
37 |
38 | ```bash
39 | # Create a new React application
40 | npx create-react-app my-softphone-app
41 | cd my-softphone-app
42 | ```
43 |
44 | ### Step 2: Install React Softphone
45 |
46 | ```bash
47 | # Install the react-softphone package
48 | npm install react-softphone
49 | ```
50 |
51 | ### Step 3: Add Audio Files
52 |
53 | Create the required audio files in your `public` directory:
54 |
55 | ```bash
56 | # Create sound directory
57 | mkdir public/sound
58 |
59 | # Add your audio files (you'll need to provide these)
60 | # public/sound/ringing.ogg - Incoming call ringtone
61 | # public/sound/ringback.ogg - Outgoing call ringback tone
62 | ```
63 |
64 | ### Step 4: Replace App.js Content
65 |
66 | Replace the contents of `src/App.js` with:
67 |
68 | ```jsx
69 | import React, { useState } from 'react';
70 | import SoftPhone from 'react-softphone';
71 | import './App.css';
72 |
73 | function App() {
74 | // State to control softphone visibility
75 | const [softPhoneOpen, setSoftPhoneOpen] = useState(false);
76 |
77 | // Your SIP server configuration
78 | const sipConfig = {
79 | domain: 'your-sip-server.com', // Your SIP domain
80 | uri: 'sip:your-extension@your-sip-server.com', // Your SIP URI
81 | password: 'your-sip-password', // Your SIP password
82 | ws_servers: 'wss://your-sip-server.com:8089/ws', // WebSocket server
83 | display_name: 'Your Name', // Display name for calls
84 | debug: false, // Set to true for debugging
85 | session_timers_refresh_method: 'invite'
86 | };
87 |
88 | // Settings state with localStorage persistence
89 | const [callVolume, setCallVolume] = useState(() => {
90 | const saved = localStorage.getItem('softphone-call-volume');
91 | return saved ? parseFloat(saved) : 0.8;
92 | });
93 |
94 | const [ringVolume, setRingVolume] = useState(() => {
95 | const saved = localStorage.getItem('softphone-ring-volume');
96 | return saved ? parseFloat(saved) : 0.6;
97 | });
98 |
99 | const [notifications, setNotifications] = useState(() => {
100 | const saved = localStorage.getItem('softphone-notifications');
101 | return saved ? JSON.parse(saved) : true;
102 | });
103 |
104 | const [connectOnStart, setConnectOnStart] = useState(() => {
105 | const saved = localStorage.getItem('softphone-connect-on-start');
106 | return saved ? JSON.parse(saved) : false;
107 | });
108 |
109 | // Functions to save settings (implement localStorage if needed)
110 | const saveConnectOnStart = (value) => {
111 | setConnectOnStart(value);
112 | localStorage.setItem('softphone-connect-on-start', value);
113 | };
114 |
115 | const saveNotifications = (value) => {
116 | setNotifications(value);
117 | localStorage.setItem('softphone-notifications', value);
118 | };
119 |
120 | const saveCallVolume = (value) => {
121 | setCallVolume(value);
122 | localStorage.setItem('softphone-call-volume', value);
123 | };
124 |
125 | const saveRingVolume = (value) => {
126 | setRingVolume(value);
127 | localStorage.setItem('softphone-ring-volume', value);
128 | };
129 |
130 | return (
131 |
188 | );
189 | }
190 |
191 | export default App;
192 | ```
193 |
194 | ### Step 5: Update App.css (Optional Styling)
195 |
196 | Add these styles to `src/App.css`:
197 |
198 | ```css
199 | .App {
200 | text-align: center;
201 | }
202 |
203 | .App-header {
204 | background-color: #282c34;
205 | padding: 20px;
206 | color: white;
207 | min-height: 100vh;
208 | display: flex;
209 | flex-direction: column;
210 | align-items: center;
211 | justify-content: center;
212 | }
213 |
214 | .App-header h1 {
215 | margin-bottom: 10px;
216 | }
217 |
218 | .App-header p {
219 | margin-bottom: 30px;
220 | opacity: 0.8;
221 | }
222 | ```
223 |
224 | ### Step 6: Configure Your SIP Settings
225 |
226 | Update the `sipConfig` object in Step 4 with your actual SIP server details:
227 |
228 | ```javascript
229 | const sipConfig = {
230 | domain: 'sip.yourprovider.com', // Replace with your SIP domain
231 | uri: 'sip:1001@sip.yourprovider.com', // Replace with your extension
232 | password: 'your-actual-password', // Replace with your SIP password
233 | ws_servers: 'wss://sip.yourprovider.com:8089/ws', // Replace with your WebSocket URL
234 | display_name: 'John Doe', // Replace with your name
235 | debug: false // Set to true for troubleshooting
236 | };
237 | ```
238 |
239 | ### Step 7: Run Your Application
240 |
241 | ```bash
242 | # Start the development server
243 | npm start
244 | ```
245 |
246 | Your app will open at `http://localhost:3000` with a working softphone!
247 |
248 | ### Step 8: Enable Debug Mode (Optional)
249 |
250 | For troubleshooting, add this to your browser console:
251 |
252 | ```javascript
253 | window.__SOFTPHONE_DEBUG__ = true;
254 | ```
255 |
256 | ## 📋 Requirements
257 |
258 | ### No Additional Dependencies Required
259 |
260 | All dependencies are bundled with the package.
261 |
262 | ## 🔧 Props Configuration
263 |
264 | ### Required Props
265 |
266 | | Property | Type | Description |
267 | |----------|------|-------------|
268 | | `config` | Object | SIP configuration object (see below) |
269 | | `setConnectOnStartToLocalStorage` | Function | Callback to save auto-connect preference |
270 | | `setNotifications` | Function | Callback to save notification preference |
271 | | `setCallVolume` | Function | Callback to save call volume |
272 | | `setRingVolume` | Function | Callback to save ring volume |
273 |
274 | ### Optional Props
275 |
276 | | Property | Type | Default | Description |
277 | |----------|------|---------|-------------|
278 | | `softPhoneOpen` | Boolean | `false` | Controls softphone visibility |
279 | | `setSoftPhoneOpen` | Function | `() => {}` | Callback when softphone opens/closes |
280 | | `callVolume` | Number | `0.5` | Call audio volume (0-1) |
281 | | `ringVolume` | Number | `0.5` | Ring audio volume (0-1) |
282 | | `connectOnStart` | Boolean | `false` | Auto-connect on component mount |
283 | | `notifications` | Boolean | `true` | Show browser notifications for calls |
284 | | `timelocale` | String | `'UTC'` | Timezone for call history |
285 | | `asteriskAccounts` | Array | `[]` | List of available accounts for transfer |
286 | | `builtInLauncher` | Boolean | `false` | Show floating launcher button |
287 | | `launcherPosition` | String | `'bottom-right'` | Launcher position (`'bottom-right'`, `'bottom-left'`, etc.) |
288 | | `launcherSize` | String | `'medium'` | Launcher size (`'small'`, `'medium'`, `'large'`) |
289 | | `launcherColor` | String | `'primary'` | Launcher color theme |
290 |
291 | ### Config Object
292 |
293 | The `config` prop must include these SIP settings:
294 |
295 | ```javascript
296 | const domain = 'your-sip-server.com';
297 | const extension = 'your-extension';
298 |
299 | const config = {
300 | domain: domain,
301 | uri: `sip:${extension}@${domain}`,
302 | password: 'your-password',
303 | ws_servers: `wss://${domain}:8089/ws`,
304 | display_name: extension,
305 | debug: false,
306 | session_timers_refresh_method: 'invite'
307 | };
308 | ```
309 |
310 | ## 🎵 Audio Files
311 |
312 | Place these audio files in your `public/sound/` directory:
313 | - `ringing.ogg` - Incoming call ringtone
314 | - `ringback.ogg` - Outgoing call ringback tone
315 |
316 | ## 🎨 Built-in Launcher
317 |
318 | The softphone includes an optional floating launcher button:
319 |
320 | ```jsx
321 |
329 | ```
330 |
331 | Available positions: `bottom-right`, `bottom-left`, `top-right`, `top-left`
332 | Available sizes: `small`, `medium`, `large`
333 | Available colors: `primary`, `secondary`, `success`, `error`, `warning`, `info`
334 |
335 | ## 📞 Call Features
336 |
337 | - **Make Calls** - Dial numbers and make outgoing calls
338 | - **Answer/Reject** - Handle incoming calls with notifications
339 | - **Hold/Resume** - Put calls on hold and resume them
340 | - **Transfer** - Transfer calls to other numbers
341 | - **Attended Transfer** - Talk to transfer target before completing
342 | - **Conference** - Merge multiple calls into conference
343 | - **Mute/Unmute** - Control microphone during calls
344 | - **Call History** - View recent call history with timestamps
345 | - **Volume Control** - Separate controls for call and ring volume
346 |
347 | ## 🌐 Browser Support
348 |
349 | - Chrome 60+
350 | - Firefox 55+
351 | - Safari 11+ (with WebRTC support)
352 | - Edge 79+
353 |
354 | ## 🐛 Debug Mode
355 |
356 | To enable debug logging for troubleshooting:
357 |
358 | ```javascript
359 | // Enable debug mode in your app
360 | window.__SOFTPHONE_DEBUG__ = true;
361 |
362 | // Now all debug messages will appear in browser console
363 | ```
364 |
365 | This will show detailed logs for:
366 | - Connection attempts
367 | - Call state changes
368 | - Notification handling
369 | - Error messages
370 |
371 | ## 🔧 Development
372 |
373 | ```bash
374 | # Clone the repository
375 | git clone https://github.com/chamuridis/react-softphone.git
376 |
377 | # Install dependencies
378 | npm install
379 |
380 | # Build the package
381 | npm run build
382 |
383 | # Create package
384 | npm pack
385 |
386 | # Install the created package in your project
387 | npm install ../react-softphone/react-softphone-*.tgz
388 |
389 | ```
390 |
391 | ## 📄 License
392 |
393 | ISC © [chamuridis](https://github.com/chamuridis)
394 |
--------------------------------------------------------------------------------
/src/phoneBlocks/KeypadBlock.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import {
3 | Grid,
4 | Fab,
5 | FormControlLabel,
6 | Switch,
7 | Tooltip,
8 | styled,
9 | IconButton,
10 | TextField,
11 | Box,
12 | Divider,
13 | Paper
14 | } from '@mui/material';
15 | import {
16 | Mic,
17 | MicOff,
18 | Settings,
19 | Pause,
20 | Call,
21 | CallEnd,
22 | Transform,
23 | PlayArrow,
24 | PhoneForwarded,
25 | Cancel,
26 | SwapCalls,
27 | CallMerge,
28 | Call as CallIcon,
29 | CallEnd as CallEndIcon,
30 | Backspace as BackspaceIcon,
31 | Mic as MicIcon,
32 | MicOff as MicOffIcon,
33 | Pause as PauseIcon,
34 | Settings as SettingsIcon,
35 | Send as TransferIcon,
36 | VideoCall as VideoCallIcon,
37 | RecordVoiceOver as RecordIcon,
38 | PresentToAll as ScreenShareIcon,
39 | Add as AddParticipantIcon
40 | } from '@mui/icons-material';
41 |
42 | import SearchList from './search-list';
43 |
44 | const Root = styled('div')(({ theme }) => ({
45 | paddingTop: theme.spacing(1),
46 | paddingBottom: theme.spacing(1)
47 | }));
48 |
49 | const FabStyled = styled(Fab)(({ theme }) => ({
50 | width: '40px',
51 | height: '40px',
52 | margin: theme.spacing(0.25),
53 | background: '#f8f9fa',
54 | color: theme.palette.text.primary,
55 | fontWeight: 'bold',
56 | fontSize: '1rem',
57 | boxShadow: '0 2px 5px rgba(0, 0, 0, 0.1)',
58 | '&:hover': {
59 | background: '#e9ecef'
60 | },
61 | '&.Mui-disabled': {
62 | background: '#f8f9fa',
63 | color: 'rgba(0, 0, 0, 0.26)'
64 | }
65 | }));
66 |
67 | const ActionFab = styled(Fab)(({ theme }) => ({
68 | width: '40px',
69 | height: '40px',
70 | margin: theme.spacing(0.25),
71 | boxShadow: '0 2px 5px rgba(0, 0, 0, 0.1)',
72 | '&.call': {
73 | background: '#4caf50',
74 | color: '#fff',
75 | '&:hover': {
76 | background: '#43a047'
77 | }
78 | },
79 | '&.end-call': {
80 | background: theme => theme.palette.error.main,
81 | color: '#fff',
82 | '&:hover': {
83 | background: theme => theme.palette.error.dark
84 | }
85 | },
86 | '&.transfer': {
87 | background: theme => theme.palette.warning.main,
88 | color: '#fff',
89 | '&:hover': {
90 | background: theme => theme.palette.warning.dark
91 | }
92 | },
93 | '&.hold': {
94 | background: theme => theme.palette.info.main,
95 | color: '#fff',
96 | '&:hover': {
97 | background: theme => theme.palette.info.dark
98 | }
99 | },
100 | '&.unhold': {
101 | background: '#4caf50',
102 | color: '#fff',
103 | '&:hover': {
104 | background: '#43a047'
105 | }
106 | },
107 | '&.mute': {
108 | background: '#9e9e9e',
109 | color: '#fff',
110 | '&:hover': {
111 | background: '#757575'
112 | }
113 | },
114 | '&.unmute': {
115 | background: '#ff5722',
116 | color: '#fff',
117 | '&:hover': {
118 | background: '#f4511e'
119 | }
120 | }
121 | }));
122 |
123 | const CallButton = styled(Fab)(({ theme }) => ({
124 | color: 'white',
125 | background: '#3acd7e',
126 | width: '40px',
127 | height: '40px',
128 | margin: theme.spacing(0.5),
129 | '&:hover': {
130 | background: '#16b364' // chateauGreen[500]
131 | }
132 | }));
133 |
134 | const EndCallButton = styled(Fab)(({ theme }) => ({
135 | color: 'white',
136 | background: theme => theme.palette.error.main,
137 | width: '40px',
138 | height: '40px',
139 | margin: theme.spacing(0.5),
140 | '&:hover': {
141 | background: theme => theme.palette.error.dark
142 | }
143 | }));
144 |
145 | const GridRaw = styled(Grid)(({ theme }) => ({
146 | display: 'flex',
147 | justifyContent: 'space-around',
148 | marginBottom: theme.spacing(1),
149 | width: '100%'
150 | }));
151 |
152 | const GridLastRaw = styled(Grid)(({ theme }) => ({
153 | display: 'flex',
154 | justifyContent: 'center',
155 | marginTop: theme.spacing(1)
156 | }));
157 |
158 | const KeypadContainer = styled('div')(({ theme }) => ({
159 | display: 'flex',
160 | flexDirection: 'column',
161 | alignItems: 'center',
162 | padding: theme.spacing(0.5)
163 | }));
164 |
165 | const QuickActionsBar = styled(Paper)(({ theme }) => ({
166 | display: 'flex',
167 | justifyContent: 'space-between',
168 | padding: theme.spacing(0.5),
169 | marginBottom: theme.spacing(1),
170 | borderRadius: theme.shape.borderRadius,
171 | backgroundColor: theme.palette.background.paper,
172 | boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.05)'
173 | }));
174 |
175 | const ActionButton = styled(IconButton)(({ theme }) => ({
176 | padding: theme.spacing(0.5),
177 | '& .MuiSvgIcon-root': {
178 | fontSize: '1rem'
179 | }
180 | }));
181 |
182 | function KeypadBlock({
183 | handleCallAttendedTransfer,
184 | handleCallTransfer,
185 | handlePressKey,
186 | handleMicMute,
187 | handleCall,
188 | handleEndCall,
189 | activeChanel,
190 | keyVariant = 'default',
191 | handleHold,
192 | asteriskAccounts = [],
193 | dialState,
194 | setDialState
195 | }) {
196 | const {
197 | inCall,
198 | muted,
199 | hold,
200 | sessionId,
201 | inAnswer,
202 | inAnswerTransfer,
203 | inConference,
204 | inTransfer,
205 | transferControl,
206 | allowTransfer,
207 | allowAttendedTransfer
208 | } = activeChanel;
209 | const [anchorElTransfer, setAnchorElTransfer] = useState(null);
210 | const [anchorElAttended, setAnchorElAttended] = useState(null);
211 | const handleClickTransferCall = (event) => {
212 | if (dialState.match(/^[0-9]+$/) != null) {
213 | handleCallTransfer(dialState);
214 | setDialState('');
215 | return;
216 | }
217 | setAnchorElTransfer(event.currentTarget);
218 | };
219 | const TransferListClick = (id) => {
220 | if (id) {
221 | handleCallTransfer(id);
222 | }
223 | };
224 | const handleClickAttendedTransfer = (event) => {
225 | if (dialState.match(/^[0-9]+$/) != null) {
226 | handleCallAttendedTransfer('transfer', {});
227 | setDialState('');
228 | return;
229 | }
230 | setAnchorElAttended(event.currentTarget);
231 | };
232 | const AttendedTransferListClick = (id) => {
233 | if (id) {
234 | handleCallAttendedTransfer('transfer', id);
235 | setDialState('');
236 | }
237 | };
238 |
239 | return (
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 | ({
255 | // Adaptive colors based on theme mode
256 | bgcolor: muted
257 | ? (theme.palette.mode === 'dark' ? '#ff5252' : '#f44336')
258 | : theme.palette.primary.main,
259 | color: '#ffffff',
260 | boxShadow: theme.palette.mode === 'dark' ? 5 : 3,
261 | border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.15)' : 'none',
262 | '&:hover': {
263 | bgcolor: muted
264 | ? (theme.palette.mode === 'dark' ? '#ff1744' : '#d32f2f')
265 | : theme.palette.primary.dark,
266 | },
267 | '&.Mui-disabled': {
268 | bgcolor: theme.palette.mode === 'dark'
269 | ? 'rgba(255,255,255,0.12)'
270 | : 'rgba(0,0,0,0.12)',
271 | color: theme.palette.mode === 'dark'
272 | ? 'rgba(255,255,255,0.3)'
273 | : 'rgba(0,0,0,0.26)'
274 | }
275 | })}
276 | >
277 | {muted ? : }
278 |
279 |
280 |
281 |
282 |
283 | {}} />}
285 | label="Mute"
286 | />
287 |
288 |
289 |
290 |
291 |
292 |
{
297 | handleHold(sessionId, hold);
298 | }}
299 | sx={theme => ({
300 | // Adaptive colors based on theme mode
301 | bgcolor: hold
302 | ? '#3acd7e'
303 | : theme.palette.primary.main,
304 | color: '#ffffff',
305 | boxShadow: theme.palette.mode === 'dark' ? 5 : 3,
306 | border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.15)' : 'none',
307 | '&:hover': {
308 | bgcolor: hold
309 | ? '#16b364'
310 | : theme.palette.primary.dark,
311 | },
312 | '&.Mui-disabled': {
313 | bgcolor: theme.palette.mode === 'dark'
314 | ? 'rgba(255,255,255,0.12)'
315 | : 'rgba(0,0,0,0.12)',
316 | color: theme.palette.mode === 'dark'
317 | ? 'rgba(255,255,255,0.3)'
318 | : 'rgba(0,0,0,0.26)'
319 | }
320 | })}
321 | >
322 | {hold ? : }
323 |
324 |
325 |
326 |
327 |
328 |
329 |
330 |
({
337 | bgcolor: theme.palette.warning.main,
338 | color: '#ffffff',
339 | boxShadow: theme.palette.mode === 'dark' ? 5 : 3,
340 | border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.15)' : 'none',
341 | '&:hover': {
342 | bgcolor: theme.palette.warning.dark,
343 | },
344 | '&.Mui-disabled': {
345 | bgcolor: theme.palette.mode === 'dark'
346 | ? 'rgba(255,255,255,0.12)'
347 | : 'rgba(0,0,0,0.12)',
348 | color: theme.palette.mode === 'dark'
349 | ? 'rgba(255,255,255,0.3)'
350 | : 'rgba(0,0,0,0.26)'
351 | }
352 | })}
353 | >
354 |
355 |
356 |
357 |
358 | TransferListClick(id)}
361 | ariaDescribedby="transferredBox"
362 | anchorEl={anchorElTransfer}
363 | setAnchorEl={setAnchorElTransfer}
364 | />
365 |
366 |
367 |
368 |
369 | ({
376 | bgcolor: theme.palette.mode === 'dark' ? '#9c27b0' : '#8e24aa',
377 | color: '#ffffff',
378 | boxShadow: theme.palette.mode === 'dark' ? 5 : 3,
379 | border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.15)' : 'none',
380 | '&:hover': {
381 | bgcolor: theme.palette.mode === 'dark' ? '#7b1fa2' : '#6a1b9a',
382 | },
383 | '&.Mui-disabled': {
384 | bgcolor: theme.palette.mode === 'dark'
385 | ? 'rgba(255,255,255,0.12)'
386 | : 'rgba(0,0,0,0.12)',
387 | color: theme.palette.mode === 'dark'
388 | ? 'rgba(255,255,255,0.3)'
389 | : 'rgba(0,0,0,0.26)'
390 | }
391 | })}
392 | >
393 |
394 |
395 |
396 |
397 |
404 |
405 | {inAnswerTransfer
406 | && !inConference
407 | && inTransfer
408 | && transferControl ? (
409 |
410 |
411 |
412 |
413 |
414 |
415 | {
420 | handleCallAttendedTransfer('merge', {});
421 | }}
422 | sx={theme => ({
423 | bgcolor: theme.palette.mode === 'dark' ? '#81c784' : '#4caf50',
424 | color: '#ffffff',
425 | boxShadow: theme.palette.mode === 'dark' ? 5 : 3,
426 | border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.15)' : 'none',
427 | '&:hover': {
428 | bgcolor: theme.palette.mode === 'dark' ? '#66bb6a' : '#388e3c',
429 | }
430 | })}
431 | >
432 |
433 |
434 |
435 |
436 |
437 |
438 |
439 |
440 | {
445 | handleCallAttendedTransfer('swap', {});
446 | }}
447 | sx={theme => ({
448 | bgcolor: theme.palette.mode === 'dark' ? '#64b5f6' : '#2196f3',
449 | color: '#ffffff',
450 | boxShadow: theme.palette.mode === 'dark' ? 5 : 3,
451 | border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.15)' : 'none',
452 | '&:hover': {
453 | bgcolor: theme.palette.mode === 'dark' ? '#42a5f5' : '#1976d2',
454 | }
455 | })}
456 | >
457 |
458 |
459 |
460 |
461 |
462 |
463 |
464 |
465 | {
470 | handleCallAttendedTransfer('finish', {});
471 | }}
472 | sx={theme => ({
473 | bgcolor: theme.palette.mode === 'dark' ? '#ffb74d' : '#ff9800',
474 | color: '#ffffff',
475 | boxShadow: theme.palette.mode === 'dark' ? 5 : 3,
476 | border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.15)' : 'none',
477 | '&:hover': {
478 | bgcolor: theme.palette.mode === 'dark' ? '#ffa726' : '#f57c00',
479 | }
480 | })}
481 | >
482 |
483 |
484 |
485 |
486 |
487 |
488 |
489 |
490 | {
495 | handleCallAttendedTransfer('cancel', {});
496 | }}
497 | sx={theme => ({
498 | bgcolor: theme.palette.error.main,
499 | color: '#ffffff',
500 | boxShadow: theme.palette.mode === 'dark' ? 5 : 3,
501 | border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.15)' : 'none',
502 | '&:hover': {
503 | bgcolor: theme.palette.error.dark,
504 | }
505 | })}
506 | >
507 |
508 |
509 |
510 |
511 |
512 |
513 |
514 |
515 | ) : (
516 |
517 | )}
518 |
519 |
520 |
521 | {inCall === false ? (
522 | ({
527 | width: '52px',
528 | height: '52px',
529 | bgcolor: '#3acd7e',
530 | color: '#ffffff',
531 | boxShadow: theme.palette.mode === 'dark' ? 8 : 4,
532 | border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.2)' : 'none',
533 | '&:hover': {
534 | bgcolor: '#16b364', // chateauGreen[500]
535 | }
536 | })}
537 | >
538 |
539 |
540 | ) : (
541 | ({
546 | width: '52px',
547 | height: '52px',
548 | bgcolor: theme.palette.mode === 'dark' ? '#ef5350' : '#f44336',
549 | color: '#ffffff',
550 | boxShadow: theme.palette.mode === 'dark' ? 8 : 4,
551 | border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.2)' : 'none',
552 | '&:hover': {
553 | bgcolor: theme.palette.mode === 'dark' ? '#e53935' : '#d32f2f',
554 | }
555 | })}
556 | >
557 |
558 |
559 | )}
560 |
561 |
562 |
563 | );
564 | }
565 | export default KeypadBlock;
566 |
--------------------------------------------------------------------------------
/src/phoneBlocks/swipe-carusel-block.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useRef } from 'react';
2 |
3 | import {
4 | Typography,
5 | Box,
6 | AppBar,
7 | Tabs,
8 | Tab,
9 | Chip,
10 | Paper,
11 | Grid
12 | } from '@mui/material';
13 | import { styled } from '@mui/material/styles';
14 | import { Phone as PhoneIcon } from '@phosphor-icons/react/dist/ssr/Phone';
15 | import { PhoneOutgoing as PhoneOutgoingIcon } from '@phosphor-icons/react/dist/ssr/PhoneOutgoing';
16 | import { PhoneIncoming as PhoneIncomingIcon } from '@phosphor-icons/react/dist/ssr/PhoneIncoming';
17 | import { PhoneX as PhoneXIcon } from '@phosphor-icons/react/dist/ssr/PhoneX';
18 | import { DeviceMobile as DeviceMobileIcon } from '@phosphor-icons/react/dist/ssr/DeviceMobile';
19 | import { ArrowsClockwise as ArrowsClockwiseIcon } from '@phosphor-icons/react/dist/ssr/ArrowsClockwise';
20 | import { PauseCircle as PauseCircleIcon } from '@phosphor-icons/react/dist/ssr/PauseCircle';
21 |
22 | function TabPanel(props) {
23 | const {
24 | children, value, index, ...other
25 | } = props;
26 |
27 | return (
28 |
36 | {value === index && {typeof children === 'function' ? children() : children}}
37 |
38 | );
39 | }
40 |
41 | function a11yProps(index) {
42 | return {
43 | id: `full-width-tab-${index}`,
44 | 'aria-controls': `full-width-tabpanel-${index}`,
45 | };
46 | }
47 |
48 | // Custom SwipeableViews component to replace the deprecated library
49 | const MuiSwipeableViews = ({
50 | index,
51 | onChangeIndex,
52 | children,
53 | // Unused parameters prefixed with underscore to satisfy linting
54 | _animateHeight = false,
55 | _resistance = true,
56 | style = {}
57 | }) => {
58 | const containerRef = useRef(null);
59 |
60 | useEffect(() => {
61 | if (containerRef.current) {
62 | const container = containerRef.current;
63 | const childCount = React.Children.count(children);
64 | if (childCount > 0 && index >= 0 && index < childCount) {
65 | const slideWidth = container.offsetWidth || 0;
66 | if (slideWidth === 0) return;
67 |
68 | container.scrollTo({
69 | left: slideWidth * index,
70 | behavior: 'smooth'
71 | });
72 | }
73 | }
74 | }, [index, children]);
75 |
76 | const handleScroll = (e) => {
77 | if (onChangeIndex && e?.currentTarget) {
78 | // Use requestAnimationFrame to avoid too many calls during scroll
79 | requestAnimationFrame(() => {
80 | const container = e.currentTarget;
81 | if (!container) return;
82 |
83 | const slideWidth = container.offsetWidth || 0;
84 | if (slideWidth === 0) return;
85 |
86 | const scrollPosition = container.scrollLeft || 0;
87 | const newIndex = Math.round(scrollPosition / slideWidth);
88 |
89 | // Only trigger change if the index actually changed and is valid
90 | if (newIndex !== index && newIndex >= 0 && newIndex < React.Children.count(children)) {
91 | onChangeIndex(newIndex);
92 | }
93 | });
94 | }
95 | };
96 |
97 | // Add a touch event handler to detect end of swipe
98 | const handleTouchEnd = () => {
99 | if (containerRef.current && onChangeIndex) {
100 | const container = containerRef.current;
101 | const slideWidth = container.offsetWidth || 0;
102 | if (slideWidth === 0) return;
103 |
104 | const scrollPosition = container.scrollLeft || 0;
105 | const newIndex = Math.round(scrollPosition / slideWidth);
106 |
107 | // Only trigger change if the index actually changed and is valid
108 | if (newIndex !== index && newIndex >= 0 && newIndex < React.Children.count(children)) {
109 | onChangeIndex(newIndex);
110 | }
111 | }
112 | };
113 |
114 | return (
115 |
131 | {React.Children.map(children, (child, _i) => (
132 |
139 | {child}
140 |
141 | ))}
142 |
143 | );
144 | };
145 |
146 | const StyledTab = styled(Tab)(() => ({
147 | textTransform: 'none',
148 | minWidth: '25%',
149 | marginRight: 'auto',
150 | fontFamily: [
151 | '-apple-system',
152 | 'BlinkMacSystemFont',
153 | '"Segoe UI"',
154 | 'Roboto',
155 | '"Helvetica Neue"',
156 | 'Arial',
157 | 'sans-serif',
158 | '"Apple Color Emoji"',
159 | '"Segoe UI Emoji"',
160 | '"Segoe UI Symbol"'
161 | ].join(','),
162 | '&:hover': {
163 | color: '#3949ab',
164 | opacity: 1
165 | },
166 | '&:focus': {
167 | cursor: 'not-allowed'
168 | }
169 | }));
170 |
171 | // Removed unused TabPanelActive
172 | /* const TabPanelActive = styled(Box)(({ theme }) => ({
173 | padding: `${theme.spacing(1)}px ${theme.spacing(3)}px`,
174 | backgroundColor: '#d0f6bb'
175 | })); */
176 |
177 | const CallInfoCard = styled(Paper)(({ theme }) => ({
178 | padding: theme.spacing(2),
179 | borderRadius: theme.shape.borderRadius,
180 | marginBottom: theme.spacing(1),
181 | backgroundColor: theme.palette.background.paper,
182 | boxShadow: theme.shadows[1]
183 | }));
184 |
185 | const StatusLabel = styled(Typography)({
186 | fontWeight: 500,
187 | marginBottom: '4px',
188 | fontSize: '0.75rem',
189 | textTransform: 'uppercase',
190 | opacity: 0.7
191 | });
192 |
193 | const StatusValue = styled(Typography)({
194 | fontWeight: 600,
195 | fontSize: '0.95rem',
196 | marginBottom: '10px',
197 | });
198 |
199 | const CallInfoGrid = styled(Grid)(({ theme }) => ({
200 | marginTop: theme.spacing(1)
201 | }));
202 |
203 | // We're using the styled components already defined above
204 |
205 | function SwipeCaruselBlock({
206 | localStatePhone, activeChannel, setActiveChannel
207 | }) {
208 | const [durations, setDurations] = useState(
209 | [{
210 | callDuration: 0,
211 | callDurationIntrId: 0,
212 | callDurationActive: false,
213 | ringDuration: 0,
214 | ringDurationIntrId: 0,
215 | ringDurationActive: false
216 | },
217 | {
218 | callDuration: 0,
219 | callDurationIntrId: 0,
220 | callDurationActive: false,
221 | ringDuration: 0,
222 | ringDurationIntrId: 0,
223 | ringDurationActive: false
224 | },
225 | {
226 | callDuration: 0,
227 | callDurationIntrId: 0,
228 | callDurationActive: false,
229 | ringDuration: 0,
230 | ringDurationIntrId: 0,
231 | ringDurationActive: false
232 | }
233 | ]
234 | );
235 | const { displayCalls } = localStatePhone;
236 | const ONE_SECOND = 1000;
237 |
238 | useEffect(() => {
239 | const interval = setInterval(() => {
240 | // Converting forEach to for...of loop for better performance and to fix lint issues
241 | for (const [key, displayCall] of displayCalls.entries()) {
242 | if (displayCall.inCall) {
243 | if (!displayCall.inAnswer && !durations[key].ringDurationActive) {
244 | setDurations((oldDurations) => ({
245 | ...oldDurations,
246 | [key]: {
247 | ...oldDurations[key],
248 | ringDuration: oldDurations[key].ringDuration + 1,
249 | }
250 | }));
251 | } else if (displayCall.inAnswer && !durations[key].callDurationActive) {
252 | setDurations((oldDurations) => ({
253 | ...oldDurations,
254 | [key]: {
255 | ...oldDurations[key],
256 | callDuration: oldDurations[key].callDuration + 1,
257 | ringDurationActive: false
258 | }
259 | }));
260 | }
261 | } else {
262 | if (durations[key].callDuration !== 0 || durations[key].ringDuration !== 0) {
263 | setDurations((oldDurations) => ({
264 | ...oldDurations,
265 | [key]: {
266 | ...oldDurations[key],
267 | callDuration: 0,
268 | callDurationActive: false,
269 | ringDuration: 0,
270 | ringDurationActive: false
271 | }
272 | }));
273 | }
274 | }
275 | }
276 | }, ONE_SECOND);
277 |
278 | return () => clearInterval(interval); // Cleanup on unmount
279 | }, [displayCalls, durations]);
280 |
281 | const handleTabChangeIndex = (index) => {
282 | setActiveChannel(index);
283 | };
284 | const handleTabChange = (event, newValue) => {
285 | setActiveChannel(newValue);
286 | };
287 |
288 | /*
289 | displayCalls.map((displayCall, key) => {
290 | // if Call just started then increment duration every one second
291 | if (displayCall.inCall === true) {
292 | if (displayCall.inAnswer === false && durations[key].ringDurationActive === false) {
293 | const intrs = setInterval(() => {
294 | setDurations((oldDurations) => ({
295 | ...oldDurations,
296 | [key]: {
297 | ...oldDurations[key],
298 | ringDuration: oldDurations[key].ringDuration + 1,
299 | ringDurationIntrId: intrs,
300 | if (displayCall.inCall === false) {
301 | if (durations[key].callDurationActive === true) {
302 | clearInterval(durations[key].callDurationIntrId);
303 |
304 | setDurations((oldDurations) => ({
305 | ...oldDurations,
306 | [key]: {
307 | ...oldDurations[key],
308 | callDuration: 0,
309 | callDurationIntrId: 0,
310 | callDurationActive: false,
311 | ringDuration: 0
312 | }
313 | }));
314 | }
315 | if (durations[key].ringDurationActive === true) {
316 | clearInterval(durations[key].ringDurationIntrId);
317 |
318 | setDurations((oldDurations) => ({
319 | ...oldDurations,
320 | [key]: {
321 | ...oldDurations[key],
322 | ringDuration: 0,
323 | ringDurationIntrId: 0,
324 | ringDurationActive: false
325 | }
326 | }));
327 | }
328 | }
329 | return true;
330 | });
331 | */
332 |
333 | return (
334 |
335 |
336 |
343 |
344 |
345 |
346 |
347 |
348 |
352 | {displayCalls.map((displayCall, key) => (
353 |
359 | {() => {
360 | if (displayCall.inCall === true) {
361 | if (displayCall.inAnswer === true) {
362 | if (displayCall.hold === true) {
363 | return (
364 | // Show hold Call info
365 |
366 | }
368 | label="On Hold"
369 | size="small"
370 | color="warning"
371 | variant="filled"
372 | sx={{ mb: 1.5 }}
373 | />
374 |
375 |
376 |
377 | Status
378 |
379 | {displayCall.callInfo}
380 |
381 |
382 |
383 |
384 | Direction
385 |
386 | {displayCall.direction === 'outgoing' ? (
387 | <>
388 |
389 | Outgoing
390 | >
391 | ) : (
392 | <>
393 |
394 | Incoming
395 | >
396 | )}
397 |
398 |
399 |
400 |
401 | Ring Duration
402 | {`${Math.floor(durations[key].ringDuration / 60).toString().padStart(2, '0')}:${(durations[key].ringDuration % 60).toString().padStart(2, '0')}`}
403 |
404 |
405 |
406 | Call Duration
407 | {`${Math.floor(durations[key].callDuration / 60).toString().padStart(2, '0')}:${(durations[key].callDuration % 60).toString().padStart(2, '0')}`}
408 |
409 |
410 |
411 | Number
412 |
413 |
414 | {displayCall.callNumber}
415 |
416 |
417 |
418 |
419 | );
420 | }
421 | if (displayCall.inTransfer === true) {
422 | return (
423 | // Show In Transfer info
424 |
425 | }
427 | label="In Transfer"
428 | size="small"
429 | color="info"
430 | variant="filled"
431 | sx={{ mb: 1.5 }}
432 | />
433 |
434 |
435 |
436 | Status
437 |
438 | {displayCall.callInfo}
439 |
440 |
441 |
442 |
443 | Direction
444 |
445 | {displayCall.direction === 'outgoing' ? (
446 | <>
447 |
448 | Outgoing
449 | >
450 | ) : (
451 | <>
452 |
453 | Incoming
454 | >
455 | )}
456 |
457 |
458 |
459 |
460 | Ring Duration
461 | {`${Math.floor(durations[key].ringDuration / 60).toString().padStart(2, '0')}:${(durations[key].ringDuration % 60).toString().padStart(2, '0')}`}
462 |
463 |
464 |
465 | Call Duration
466 | {`${Math.floor(durations[key].callDuration / 60).toString().padStart(2, '0')}:${(durations[key].callDuration % 60).toString().padStart(2, '0')}`}
467 |
468 |
469 |
470 | Number
471 |
472 |
473 | {displayCall.callNumber}
474 |
475 |
476 |
477 |
478 | Transfer To
479 |
480 | {displayCall.transferNumber}
481 |
482 |
483 |
484 | {displayCall.attendedTransferOnline.length > 1 && !displayCall.inConference && (
485 |
486 | Talking With
487 |
488 | {displayCall.attendedTransferOnline}
489 |
490 |
491 | )}
492 |
493 |
494 | );
495 | }
496 |
497 | return (
498 | // Show In Call info
499 |
500 | }
502 | label="Active Call"
503 | size="small"
504 | color="success"
505 | variant="filled"
506 | sx={{ mb: 1.5 }}
507 | />
508 |
509 |
510 |
511 | Status
512 |
513 | {displayCall.callInfo}
514 |
515 |
516 |
517 |
518 | Direction
519 |
520 | {displayCall.direction === 'outgoing' ? (
521 | <>
522 |
523 | Outgoing
524 | >
525 | ) : (
526 | <>
527 |
528 | Incoming
529 | >
530 | )}
531 |
532 |
533 |
534 |
535 | Ring Duration
536 | {`${Math.floor(durations[key].ringDuration / 60).toString().padStart(2, '0')}:${(durations[key].ringDuration % 60).toString().padStart(2, '0')}`}
537 |
538 |
539 |
540 | Call Duration
541 |
542 | {`${Math.floor(durations[key].callDuration / 60).toString().padStart(2, '0')}:${(durations[key].callDuration % 60).toString().padStart(2, '0')}`}
543 |
544 |
545 |
546 |
547 | Number
548 |
549 |
550 | {displayCall.callNumber}
551 |
552 |
553 |
554 |
555 | );
556 | }
557 |
558 | return (
559 | // Show Calling/Ringing info
560 |
561 | }
563 | label="Ringing"
564 | size="small"
565 | color="warning"
566 | variant="filled"
567 | sx={{ mb: 1.5 }}
568 | />
569 |
570 |
571 |
572 | Status
573 |
574 | {displayCall.callInfo}
575 |
576 |
577 |
578 |
579 | Direction
580 |
581 | {displayCall.direction === 'outgoing' ? (
582 | <>
583 |
584 | Outgoing
585 | >
586 | ) : (
587 | <>
588 |
589 | Incoming
590 | >
591 | )}
592 |
593 |
594 |
595 |
596 | Ring Duration
597 |
598 | {`${Math.floor(durations[key].ringDuration / 60).toString().padStart(2, '0')}:${(durations[key].ringDuration % 60).toString().padStart(2, '0')}`}
599 |
600 |
601 |
602 |
603 | Number
604 |
605 |
606 | {displayCall.callNumber}
607 |
608 |
609 |
610 |
611 | );
612 | }
613 |
614 | return (
615 | // Show Ready info
616 |
617 | }
619 | label="Ready"
620 | size="small"
621 | color="primary"
622 | variant="filled"
623 | sx={{ mb: 1.5 }}
624 | />
625 |
626 |
627 |
628 | Status
629 |
630 | {displayCall.callInfo} {displayCall.info}
631 |
632 |
633 |
634 |
635 | );
636 | }}
637 |
638 |
639 | ))}
640 |
641 |
642 |
643 | );
644 | }
645 |
646 | export default SwipeCaruselBlock;
647 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from 'react';
2 | import {
3 | Drawer,
4 | Fab,
5 | TextField,
6 | IconButton,
7 | InputAdornment,
8 | Alert,
9 | Snackbar,
10 | Typography,
11 | Box,
12 | Divider,
13 | Chip
14 | } from '@mui/material';
15 | import { styled } from '@mui/material/styles';
16 | import {
17 | Phone as PhoneIcon,
18 | Close as CloseIcon,
19 | Call as CallIcon,
20 | CallEnd as CallEndIcon,
21 | ChevronRight as ChevronRightIcon,
22 | Clear as XIcon
23 | } from '@mui/icons-material';
24 | import _ from 'lodash';
25 | import { WebSocketInterface } from 'jssip';
26 | import KeypadBlock from './phoneBlocks/KeypadBlock';
27 | import SwipeCaruselBlock from './phoneBlocks/swipe-carusel-block';
28 | import SwipeCaruselBodyBlock from './phoneBlocks/SwipeCaruselBodyBlock';
29 | import StatusBlock from './phoneBlocks/status-block';
30 | import CallQueue from './phoneBlocks/call-queue';
31 | import CallsFlowControl from './CallsFlowControl';
32 | import { NOTIFICATION_DEFAULTS, debugLog, debugError, debugWarn, hasNotificationAPI, isBrowser } from './constants';
33 |
34 | const flowRoute = new CallsFlowControl();
35 |
36 | // Modern styled components with MUI v6 best practices
37 | const PhoneRoot = styled('div')(({ theme }) => ({
38 | paddingTop: theme.spacing(3),
39 | paddingBottom: theme.spacing(3),
40 | display: 'flex',
41 | flexDirection: 'column',
42 | height: '100%'
43 | }));
44 |
45 | const Results = styled('div')(({ theme }) => ({
46 | marginTop: theme.spacing(3),
47 | flexGrow: 1,
48 | overflow: 'auto'
49 | }));
50 |
51 | const DrawerStyled = styled(Drawer)(({ theme }) => ({
52 | width: '320px',
53 | flexShrink: 0,
54 | '& .MuiDrawer-paper': {
55 | width: '320px',
56 | boxSizing: 'border-box',
57 | boxShadow: theme.shadows[3],
58 | borderRadius: theme.shape.borderRadius * 2 + 'px 0 0 ' + theme.shape.borderRadius * 2 + 'px',
59 | display: 'flex',
60 | flexDirection: 'column',
61 | backgroundColor: theme.palette.background.paper,
62 | borderRight: 'none',
63 | overflow: 'hidden'
64 | }
65 | }));
66 |
67 | const DrawerHeader = styled('div')(({ theme }) => ({
68 | display: 'flex',
69 | alignItems: 'center',
70 | padding: theme.spacing(0, 1),
71 | justifyContent: 'space-between',
72 | height: '48px'
73 | }));
74 |
75 | const PhoneTextFieldStyled = styled(TextField)(({ theme }) => ({
76 | margin: theme.spacing(2, 1, 1.5, 1),
77 | '& .MuiInputBase-root': {
78 | borderRadius: theme.shape.borderRadius * 1.5,
79 | backgroundColor: theme.palette.mode === 'dark'
80 | ? theme.palette.background.paper
81 | : theme.palette.grey[50],
82 | padding: theme.spacing(0.5, 1),
83 | transition: 'all 0.2s ease-in-out',
84 | '&:hover': {
85 | backgroundColor: theme.palette.mode === 'dark'
86 | ? theme.palette.action.hover
87 | : theme.palette.grey[100],
88 | },
89 | '&.Mui-focused': {
90 | boxShadow: `0 0 0 2px ${theme.palette.primary.main}25`,
91 | }
92 | },
93 | '& .MuiOutlinedInput-notchedOutline': {
94 | borderColor: theme.palette.mode === 'dark'
95 | ? 'rgba(255, 255, 255, 0.15)'
96 | : 'rgba(0, 0, 0, 0.1)',
97 | },
98 | '& .MuiInputBase-input': {
99 | fontSize: '1.25rem',
100 | letterSpacing: '0.05em',
101 | fontWeight: 500,
102 | }
103 | }));
104 |
105 | const Connected = styled('span')({
106 | color: 'green'
107 | });
108 |
109 | const Disconnected = styled('span')({
110 | color: 'red'
111 | });
112 |
113 | // Used for call buttons in the keypad
114 | const _PhoneFab = styled(Fab)(({ theme }) => ({
115 | position: 'absolute',
116 | top: 0,
117 | left: 0,
118 | right: 0,
119 | bottom: 0,
120 | margin: 'auto',
121 | width: '56px',
122 | height: '56px',
123 | boxShadow: theme.shadows[4],
124 | '&:hover': {
125 | boxShadow: theme.shadows[6],
126 | },
127 | '& .MuiSvgIcon-root': {
128 | fontSize: '1.5rem'
129 | }
130 | }));
131 |
132 | const TextForm = styled('div')({
133 | '& > *': {
134 | textAlign: 'right',
135 | width: '100%'
136 | },
137 | '.MuiInputBase-input': {
138 | textAlign: 'right'
139 | }
140 | });
141 |
142 | // Launcher styles
143 | const LauncherFab = styled(Fab)(({ theme, position, size }) => {
144 | const positions = {
145 | 'bottom-right': { bottom: 24, right: 24 },
146 | 'bottom-left': { bottom: 24, left: 24 },
147 | 'top-right': { top: 24, right: 24 },
148 | 'top-left': { top: 24, left: 24 }
149 | };
150 |
151 | const sizes = {
152 | small: { width: 40, height: 40 },
153 | medium: { width: 56, height: 56 },
154 | large: { width: 72, height: 72 }
155 | };
156 |
157 | return {
158 | position: 'fixed',
159 | zIndex: theme.zIndex.speedDial,
160 | boxShadow: theme.shadows[6],
161 | '&:hover': {
162 | boxShadow: theme.shadows[8],
163 | },
164 | ...positions[position],
165 | ...sizes[size]
166 | };
167 | });
168 |
169 | function SoftPhone({
170 | timelocale,
171 | setConnectOnStartToLocalStorage,
172 | connectOnStart,
173 | asteriskAccounts,
174 | setNotifications,
175 | notifications,
176 | setCallVolume,
177 | setRingVolume,
178 | softPhoneOpen,
179 | setSoftPhoneOpen,
180 | callVolume,
181 | ringVolume,
182 | config,
183 | builtInLauncher = false,
184 | launcherPosition = 'bottom-right',
185 | launcherSize = 'medium',
186 | launcherColor = 'primary',
187 | }) {
188 | const player = useRef(null);
189 | const ringer = useRef(null);
190 |
191 | // Built-in launcher state
192 | const [launcherOpen, setLauncherOpen] = useState(false);
193 |
194 | const defaultSoftPhoneState = {
195 | displayCalls: [
196 | {
197 | id: 0,
198 | info: 'Ch 1',
199 | hold: false,
200 | muted: 0,
201 | autoMute: 0,
202 | inCall: false,
203 | inAnswer: false,
204 | inTransfer: false,
205 | callInfo: 'Ready',
206 | inAnswerTransfer: false,
207 | allowTransfer: true,
208 | transferControl: false,
209 | allowAttendedTransfer: true,
210 | transferNumber: '',
211 | attendedTransferOnline: '',
212 | inConference: false,
213 | callNumber: '',
214 | duration: 0,
215 | side: '',
216 | sessionId: ''
217 | },
218 | {
219 | id: 1,
220 | info: 'Ch 2',
221 | hold: false,
222 | muted: 0,
223 | autoMute: 0,
224 | inCall: false,
225 | inAnswer: false,
226 | inAnswerTransfer: false,
227 | inConference: false,
228 | inTransfer: false,
229 | callInfo: 'Ready',
230 | allowTransfer: true,
231 | transferControl: false,
232 | allowAttendedTransfer: true,
233 | transferNumber: '',
234 | attendedTransferOnline: '',
235 | callNumber: '',
236 | duration: 0,
237 | side: '',
238 | sessionId: ''
239 | },
240 | {
241 | id: 2,
242 | info: 'Ch 3',
243 | hold: false,
244 | muted: 0,
245 | autoMute: 0,
246 | inCall: false,
247 | inConference: false,
248 | inAnswer: false,
249 | callInfo: 'Ready',
250 | inTransfer: false,
251 | inAnswerTransfer: false,
252 | Transfer: false,
253 | allowTransfer: true,
254 | transferControl: false,
255 | allowAttendedTransfer: true,
256 | transferNumber: '',
257 | attendedTransferOnline: '',
258 | callNumber: '',
259 | duration: 0,
260 | side: '',
261 | sessionId: ''
262 | }
263 | ],
264 | connectOnStart: connectOnStart,
265 | notifications,
266 | phoneCalls: [],
267 | connectedPhone: false,
268 | connectingPhone: false,
269 | activeCalls: [],
270 | callVolume: typeof callVolume === 'number' && !isNaN(callVolume) ? callVolume : 0.5,
271 | ringVolume: typeof ringVolume === 'number' && !isNaN(ringVolume) ? ringVolume : 0.5,
272 | userPresence: 'available', // New user presence state
273 | darkMode: false, // New dark mode state
274 | };
275 | const [drawerOpen, drawerSetOpen] = useState(softPhoneOpen);
276 | const [dialState, setDialState] = useState('');
277 | const [activeChannel, setActiveChannel] = useState(0);
278 | const [localStatePhone, setLocalStatePhone] = useState(defaultSoftPhoneState);
279 | const [notificationState, setNotificationState] = useState({ open: false, message: '' });
280 | const [calls, setCalls] = useState([]);
281 |
282 | // Keep drawerOpen in sync with softPhoneOpen prop
283 | useEffect(() => {
284 | drawerSetOpen(softPhoneOpen);
285 | }, [softPhoneOpen]);
286 |
287 |
288 | const notify = (message) => {
289 | // Safely update notification state
290 | if (message) {
291 | setNotificationState({ open: true, message });
292 | }
293 | };
294 |
295 | // Request permission only when user interacts with notifications setting
296 | const requestNotificationPermission = () => {
297 | if (hasNotificationAPI()) {
298 | window.Notification.requestPermission().then((permission) => {
299 | debugLog('Notification permission:', permission);
300 | });
301 | } else {
302 | debugLog('Notification API not available in this environment');
303 | }
304 | };
305 |
306 | const handleClose = (event, reason) => {
307 | if (reason === 'clickaway') {
308 | return;
309 | }
310 |
311 | setNotificationState((notification) => ({ ...notification, open: false }));
312 | };
313 |
314 | // Safety check to ensure flowRoute exists before setting properties
315 | if (flowRoute) {
316 | flowRoute.activeChanel = localStatePhone.displayCalls[activeChannel];
317 | flowRoute.connectedPhone = localStatePhone.connectedPhone;
318 | flowRoute.engineEvent = (event, payload) => {
319 | // Listen Here for Engine "UA jssip" events
320 | switch (event) {
321 | case 'connecting':
322 | break;
323 | case 'connected':
324 | setLocalStatePhone((prevState) => ({
325 | ...prevState,
326 | connectingPhone: false,
327 | connectedPhone: true
328 | }));
329 | break;
330 | case 'registered':
331 | break;
332 | case 'disconnected':
333 | setLocalStatePhone((prevState) => ({
334 | ...prevState,
335 | connectingPhone: false,
336 | connectedPhone: false
337 | }));
338 | break;
339 | case 'registrationFailed':
340 | break;
341 |
342 | default:
343 | break;
344 | }
345 | };
346 |
347 | // Not currently used but kept for potential future use
348 | const _getStatusDetails = (state) => {
349 | switch (state) {
350 | case 'connected':
351 | return { color: 'primary', label: 'Connected', icon: };
352 | case 'registered':
353 | return { color: 'primary', label: 'Registered', icon: };
354 | case 'disconnected':
355 | return { color: 'primary', label: 'Disconnected', icon: };
356 | case 'connecting':
357 | return { color: 'primary', label: 'Connecting...', icon: };
358 | case 'new':
359 | return { color: 'primary', label: 'Ready', icon: };
360 | default:
361 | return { color: 'primary', label: 'Offline', icon: };
362 | }
363 | };
364 | }
365 |
366 | if (flowRoute) {
367 | flowRoute.onCallActionConnection = async (type, payload, data) => {
368 | switch (type) {
369 | case 'reinvite':
370 | // looks like its Attended Transfer
371 | // Success transfer
372 | setLocalStatePhone((prevState) => ({
373 | ...prevState,
374 | displayCalls: _.map(localStatePhone.displayCalls, (a) => (a.sessionId === payload ? {
375 | ...a,
376 | allowAttendedTransfer: true,
377 | allowTransfer: true,
378 | inAnswerTransfer: true,
379 | inTransfer: true,
380 | attendedTransferOnline: data.request.headers['P-Asserted-Identity'][0].raw.split(' ')[0]
381 |
382 | } : a))
383 | }));
384 |
385 | break;
386 | case 'incomingCall':
387 | // looks like new call its incoming call
388 | // Save new object with the Phone data of new incoming call into the array with Phone data
389 | setLocalStatePhone((prevState) => ({
390 | ...prevState,
391 | phoneCalls: [
392 | ...prevState.phoneCalls,
393 | {
394 | callNumber: (payload.remote_identity.display_name !== '') ? `${payload.remote_identity.display_name || ''}` : payload.remote_identity.uri.user,
395 | sessionId: payload.id,
396 | ring: false,
397 | duration: 0,
398 | direction: payload.direction
399 | }
400 | ]
401 | }));
402 | // Show notification for incoming calls
403 | debugLog('Incoming call received, preparing notification')
404 |
405 | // Check if the environment supports notifications (browser only)
406 | if (hasNotificationAPI()) {
407 | // Check if permission is already granted
408 | if (window.Notification.permission === 'granted') {
409 | try {
410 | debugLog('Creating notification - permission already granted')
411 | const notification = new window.Notification(NOTIFICATION_DEFAULTS.TITLE, {
412 | icon: NOTIFICATION_DEFAULTS.ICON,
413 | body: `Caller: ${(payload.remote_identity.display_name !== '') ? `${payload.remote_identity.display_name || ''}` : payload.remote_identity.uri.user}`
414 | });
415 |
416 | // Use proper event listener instead of onclick property
417 | const handleNotificationClick = function() {
418 | if (isBrowser()) {
419 | window.parent.focus();
420 | window.focus(); // just in case, older browsers
421 | notification.close();
422 | }
423 | };
424 | notification.addEventListener('click', handleNotificationClick);
425 |
426 | debugLog('Notification created successfully');
427 | } catch (error) {
428 | debugError('Error creating notification:', error);
429 | }
430 | }
431 | // Check if permission is 'default' (not yet decided)
432 | else if (window.Notification.permission === 'default') {
433 | // Request permission
434 | debugLog('Requesting notification permission')
435 | window.Notification.requestPermission().then(permission => {
436 | if (permission === 'granted') {
437 | try {
438 | debugLog('Creating notification after permission granted')
439 | const notification = new window.Notification(NOTIFICATION_DEFAULTS.TITLE, {
440 | icon: NOTIFICATION_DEFAULTS.ICON,
441 | body: `Caller: ${(payload.remote_identity.display_name !== '') ? `${payload.remote_identity.display_name || ''}` : payload.remote_identity.uri.user}`
442 | });
443 |
444 | // Use proper event listener instead of onclick property
445 | const handleNotificationClick = function() {
446 | if (isBrowser()) {
447 | window.parent.focus();
448 | window.focus();
449 | notification.close();
450 | }
451 | };
452 | notification.addEventListener('click', handleNotificationClick);
453 |
454 | debugLog('Notification created successfully after permission');
455 | } catch (error) {
456 | debugError('Error creating notification after permission:', error);
457 | }
458 | } else {
459 | debugLog('Notification permission denied');
460 | }
461 | });
462 | } else {
463 | debugLog('Notification permission was previously denied');
464 | }
465 | } else {
466 | debugLog('Notification API not available in this environment (React Native or server-side)');
467 | }
468 |
469 | break;
470 | case 'outgoingCall':
471 | // looks like new call its outgoing call
472 | // Create object with the Display data of new outgoing call
473 |
474 | const newProgressLocalStatePhone = _.cloneDeep(localStatePhone);
475 | newProgressLocalStatePhone.displayCalls[activeChannel] = {
476 | ...localStatePhone.displayCalls[activeChannel],
477 | inCall: true,
478 | hold: false,
479 | inAnswer: false,
480 | direction: payload.direction,
481 | sessionId: payload.id,
482 | callNumber: payload.remote_identity.uri.user,
483 | callInfo: 'Ringing'
484 | };
485 | // Save new object into the array with display calls
486 |
487 | setLocalStatePhone((prevState) => ({
488 | ...prevState,
489 | displayCalls: newProgressLocalStatePhone.displayCalls
490 | }));
491 | setDialState('');
492 |
493 | break;
494 | case 'callEnded':
495 | // Call is ended, lets delete the call from calling queue
496 | // Call is ended, lets check and delete the call from display calls list
497 | // const ifExist= _.findIndex(localStatePhone.displayCalls,{sessionId:e.sessionId})
498 | setLocalStatePhone((prevState) => ({
499 | ...prevState,
500 | phoneCalls: localStatePhone.phoneCalls.filter((item) => item.sessionId !== payload),
501 | displayCalls: _.map(localStatePhone.displayCalls, (a) => (a.sessionId === payload ? {
502 | ...a,
503 | inCall: false,
504 | inAnswer: false,
505 | hold: false,
506 | muted: 0,
507 | inTransfer: false,
508 | inAnswerTransfer: false,
509 | allowFinishTransfer: false,
510 | allowTransfer: true,
511 | allowAttendedTransfer: true,
512 | inConference: false,
513 | callInfo: 'Ready'
514 |
515 | } : a))
516 | }));
517 |
518 | const firstCheck = localStatePhone.phoneCalls.filter((item) => item.sessionId === payload && item.direction === 'incoming');
519 | const secondCheck = localStatePhone.displayCalls.filter((item) => item.sessionId === payload);
520 | if (firstCheck.length === 1) {
521 | setCalls((call) => [{
522 | status: 'missed',
523 | sessionId: firstCheck[0].sessionId,
524 | direction: firstCheck[0].direction,
525 | number: firstCheck[0].callNumber,
526 | time: new Date()
527 | }, ...call]);
528 | } else if (secondCheck.length === 1) {
529 | setCalls((call) => [{
530 | status: secondCheck[0].inAnswer ? 'answered' : 'missed',
531 | sessionId: secondCheck[0].sessionId,
532 | direction: secondCheck[0].direction,
533 | number: secondCheck[0].callNumber,
534 | time: new Date()
535 | }, ...call]);
536 | }
537 | break;
538 | case 'callAccepted':
539 | // Established conection
540 | // Set caller number for Display calls
541 | let displayCallId = data.customPayload;
542 | let acceptedCall = localStatePhone.phoneCalls.filter((item) => item.sessionId === payload);
543 |
544 | if (!acceptedCall[0]) {
545 | acceptedCall = localStatePhone.displayCalls.filter((item) => item.sessionId === payload);
546 | displayCallId = acceptedCall[0].id;
547 | }
548 |
549 | // Call is Established
550 | // Lets make a copy of localStatePhone Object
551 | const newAcceptedLocalStatePhone = _.cloneDeep(localStatePhone);
552 | // Lets check and delete the call from phone calls list
553 | const newAcceptedPhoneCalls = newAcceptedLocalStatePhone.phoneCalls.filter((item) => item.sessionId !== payload);
554 | // Save to the local state
555 | setLocalStatePhone((prevState) => ({
556 | ...prevState,
557 | phoneCalls: newAcceptedPhoneCalls,
558 | displayCalls: _.map(localStatePhone.displayCalls, (a) => (a.id === displayCallId ? {
559 | ...a,
560 | callNumber: acceptedCall[0].callNumber,
561 | sessionId: payload,
562 | duration: 0,
563 | direction: acceptedCall[0].direction,
564 | inCall: true,
565 | inAnswer: true,
566 | hold: false,
567 | callInfo: 'Answered'
568 | } : a))
569 | }));
570 |
571 | break;
572 | case 'hold':
573 |
574 | // let holdCall = localStatePhone.displayCalls.filter((item) => item.sessionId === payload);
575 |
576 | setLocalStatePhone((prevState) => ({
577 | ...prevState,
578 | displayCalls: _.map(localStatePhone.displayCalls, (a) => (a.sessionId === payload ? {
579 | ...a,
580 | hold: true
581 | } : a))
582 | }));
583 | break;
584 | case 'unhold':
585 |
586 | setLocalStatePhone((prevState) => ({
587 | ...prevState,
588 | displayCalls: _.map(localStatePhone.displayCalls, (a) => (a.sessionId === payload ? {
589 | ...a,
590 | hold: false
591 | } : a))
592 | }));
593 | break;
594 | case 'unmute':
595 |
596 | setLocalStatePhone((prevState) => ({
597 | ...prevState,
598 | displayCalls: _.map(localStatePhone.displayCalls, (a) => (a.sessionId === payload ? {
599 | ...a,
600 | muted: 0
601 | } : a))
602 | }));
603 | break;
604 | case 'mute':
605 |
606 | setLocalStatePhone((prevState) => ({
607 | ...prevState,
608 | displayCalls: _.map(localStatePhone.displayCalls, (a) => (a.sessionId === payload ? {
609 | ...a,
610 | muted: 1
611 | } : a))
612 | }));
613 | break;
614 | case 'notify':
615 | notify(payload);
616 | break;
617 | default:
618 | break;
619 | }
620 | };
621 | }
622 |
623 | const handleSettingsSlider = (name, newValue) => {
624 | // Ensure we have a valid number between 0 and 1
625 | const safeValue = typeof newValue === 'number' && !Number.isNaN(newValue) ?
626 | Math.max(0, Math.min(1, newValue)) : 0;
627 |
628 | // Use safeValue for all operations
629 | setLocalStatePhone((prevState) => ({
630 | ...prevState,
631 | [name]: safeValue
632 | }));
633 |
634 | switch (name) {
635 | case 'ringVolume':
636 | if (ringer.current) {
637 | ringer.current.volume = safeValue;
638 | }
639 | if (flowRoute?.ringbackTone) {
640 | flowRoute.ringbackTone.volume = safeValue;
641 | }
642 | setRingVolume(safeValue);
643 | break;
644 |
645 | case 'callVolume':
646 | if (player.current) {
647 | player.current.volume = safeValue;
648 | }
649 | setCallVolume(safeValue);
650 | break;
651 |
652 | default:
653 | break;
654 | }
655 | };
656 | const handleConnectPhone = (event, connectionStatus) => {
657 | try {
658 | if (event) {
659 | event.persist();
660 | }
661 | } catch (e) {
662 | // Error handling without logging
663 | }
664 | setLocalStatePhone((prevState) => ({
665 | ...prevState,
666 | connectingPhone: true
667 | }));
668 | if (flowRoute) {
669 | if (connectionStatus === true) {
670 | flowRoute.start();
671 | } else {
672 | flowRoute.stop();
673 | }
674 | }
675 |
676 |
677 |
678 | return true;
679 | };
680 | const toggleDrawer = (openDrawer) => (event) => {
681 | if (event.type === 'keydown' && (event.key === 'Tab' || event.key === 'Shift')) {
682 | return;
683 | }
684 | setSoftPhoneOpen(openDrawer);
685 | drawerSetOpen(openDrawer);
686 | };
687 | const handleDialStateChange = (event) => {
688 | event.persist();
689 | setDialState(event.target.value);
690 | };
691 | const handleConnectOnStart = (event,newValue) => {
692 | event.persist();
693 | setLocalStatePhone((prevState) => ({
694 | ...prevState,
695 | connectOnStart: newValue
696 | }));
697 |
698 | setConnectOnStartToLocalStorage(newValue);
699 | };
700 | const handleNotifications = (event, newValue) => {
701 | event.persist();
702 | setLocalStatePhone((prevState) => ({
703 | ...prevState,
704 | notifications: newValue
705 | }));
706 |
707 | setNotifications(newValue);
708 | if (newValue) {
709 | requestNotificationPermission();
710 | }
711 | };
712 | const handlePressKey = (event) => {
713 | event.persist();
714 | setDialState(dialState + event.currentTarget.value);
715 | };
716 |
717 | const handleCall = (event) => {
718 | event.persist();
719 | if (dialState.match(/^[0-9]+$/) != null && flowRoute) {
720 | flowRoute.call(dialState);
721 | }
722 | };
723 |
724 | const handleEndCall = (event) => {
725 | event.persist();
726 | if (flowRoute) {
727 | flowRoute.hungup(localStatePhone.displayCalls[activeChannel].sessionId);
728 | }
729 | };
730 | const handleHold = (sessionId, hold) => {
731 | if (!flowRoute) return;
732 | if (hold === false) {
733 | flowRoute.hold(sessionId);
734 | } else if (hold === true) {
735 | flowRoute.unhold(sessionId);
736 | }
737 | };
738 | const handleAnswer = (event) => {
739 | if (flowRoute) {
740 | flowRoute.answer(event.currentTarget.value);
741 | }
742 | };
743 | const handleReject = (event) => {
744 | if (flowRoute) {
745 | flowRoute.hungup(event.currentTarget.value);
746 | }
747 | };
748 | const handleMicMute = () => {
749 | if (flowRoute) {
750 | flowRoute.setMicMuted();
751 | }
752 | };
753 |
754 | const handleCallTransfer = (transferedNumber) => {
755 | if (!dialState && !transferedNumber) return;
756 | const newCallTransferDisplayCalls = _.map(
757 | localStatePhone.displayCalls, (a) => (a.id === activeChannel ? {
758 | ...a,
759 | transferNumber: dialState || transferedNumber,
760 | inTransfer: true,
761 | allowAttendedTransfer: false,
762 | allowFinishTransfer: false,
763 | allowTransfer: false,
764 | callInfo: 'Transferring...'
765 | } : a)
766 | );
767 | setLocalStatePhone((prevState) => ({
768 | ...prevState,
769 | displayCalls: newCallTransferDisplayCalls
770 | }));
771 | if (flowRoute?.activeCall) {
772 | flowRoute.activeCall.sendDTMF(`##${dialState || transferedNumber}`);
773 | }
774 | };
775 |
776 | const handleCallAttendedTransfer = (event, number) => {
777 | switch (event) {
778 | case 'transfer':
779 | setLocalStatePhone((prevState) => ({
780 | ...prevState,
781 | displayCalls: _.map(localStatePhone.displayCalls, (a) => (a.id === activeChannel ? {
782 | ...a,
783 | transferNumber: dialState || number,
784 | allowAttendedTransfer: false,
785 | allowTransfer: false,
786 | transferControl: true,
787 | allowFinishTransfer: false,
788 | callInfo: 'Attended Transfer',
789 | inTransfer: true
790 | } : a))
791 | }));
792 | if (flowRoute?.activeCall) {
793 | flowRoute.activeCall.sendDTMF(`*2${dialState || number}`);
794 | }
795 | break;
796 | case 'merge':
797 | const newCallMergeAttendedTransferDisplayCalls = _.map(
798 | localStatePhone.displayCalls,
799 | (a) => (a.sessionId === localStatePhone.displayCalls[activeChannel].sessionId ? {
800 | ...a,
801 | inTransfer: false,
802 | attendedTransferOnline: ''
803 | } : a)
804 | );
805 | setLocalStatePhone((prevState) => ({
806 | ...prevState,
807 | displayCalls: newCallMergeAttendedTransferDisplayCalls
808 | }));
809 |
810 | if (flowRoute?.activeCall) {
811 | flowRoute.activeCall.sendDTMF('*5');
812 | }
813 | break;
814 | case 'swap':
815 | if (flowRoute?.activeCall) {
816 | flowRoute.activeCall.sendDTMF('*6');
817 | }
818 | break;
819 | case 'finish':
820 | if (flowRoute?.activeCall) {
821 | flowRoute.activeCall.sendDTMF('*4');
822 | }
823 | break;
824 | case 'cancel':
825 | const newCallCancelAttendedTransferDisplayCalls = _.map(
826 | localStatePhone.displayCalls,
827 | (a) => (a.sessionId === localStatePhone.displayCalls[activeChannel].sessionId ? {
828 | ...a,
829 | inTransfer: false,
830 | attendedTransferOnline: '',
831 | transferControl: false,
832 | allowTransfer: true,
833 | allowAttendedTransfer: true
834 | } : a)
835 | );
836 | setLocalStatePhone((prevState) => ({
837 | ...prevState,
838 | displayCalls: newCallCancelAttendedTransferDisplayCalls
839 | }));
840 | if (flowRoute?.activeCall) {
841 | flowRoute.activeCall.sendDTMF('*3');
842 | }
843 | break;
844 | default:
845 | break;
846 | }
847 | };
848 | const handleSettingsButton = () => {
849 | if (flowRoute) {
850 | flowRoute.tmpEvent();
851 | }
852 | };
853 |
854 | const handleDarkMode = (checked) => {
855 | setLocalStatePhone({
856 | ...localStatePhone,
857 | darkMode: checked
858 | });
859 | };
860 |
861 | const handleUserPresence = (status) => {
862 | setLocalStatePhone({
863 | ...localStatePhone,
864 | userPresence: status
865 | });
866 | };
867 |
868 | useEffect(() => {
869 | if (flowRoute) {
870 | flowRoute.config = {
871 | ...config,
872 | sockets: new WebSocketInterface(config.ws_servers)
873 | };
874 | flowRoute.init();
875 | if (localStatePhone.connectOnStart) {
876 | handleConnectPhone(null, true);
877 | }
878 | }
879 |
880 | try {
881 | player.current.defaultMuted = false;
882 | player.current.autoplay = true;
883 |
884 | // Set volume safely with validation
885 | const safeCallVolume = typeof localStatePhone.callVolume === 'number' && !Number.isNaN(localStatePhone.callVolume) ?
886 | Math.max(0, Math.min(1, localStatePhone.callVolume)) : 0.5;
887 | player.current.volume = safeCallVolume;
888 |
889 | if (flowRoute) {
890 | flowRoute.player = player;
891 | }
892 | ringer.current.src = '/sound/ringing.ogg';
893 | ringer.current.loop = true;
894 |
895 | const safeRingVolume = localStatePhone.ringVolume ?
896 | Math.max(0, Math.min(1, localStatePhone.ringVolume)) : 0.5;
897 | ringer.current.volume = safeRingVolume;
898 |
899 | if (flowRoute) {
900 | flowRoute.ringer = ringer;
901 | // Add a new element for the "beep beep" ringback tone
902 | const ringbackTone = new Audio('/sound/ringback.ogg');
903 | ringbackTone.loop = true;
904 | ringbackTone.volume = safeRingVolume;
905 | flowRoute.ringbackTone = ringbackTone; // Attach it to the flowRoute object
906 | }
907 | // Prevent media keys from controlling playback
908 | if ('mediaSession' in navigator) {
909 | // Override default media key behaviors
910 | navigator.mediaSession.setActionHandler('play', () => {
911 | // Prevent default play behavior for all media elements
912 | debugLog('Media play key blocked to prevent playing the ringer');
913 |
914 | });
915 | }
916 | } catch (e) {
917 | debugError('Media session error:', e);
918 | }
919 | }, []);
920 | const dialNumberOnEnter = (event) => {
921 | if (event.key === 'Enter') {
922 | handleCall(event);
923 | }
924 | };
925 | // Handle launcher toggle
926 | const handleLauncherToggle = () => {
927 | if (builtInLauncher) {
928 | setLauncherOpen(!launcherOpen);
929 | setSoftPhoneOpen(!launcherOpen);
930 | }
931 | };
932 |
933 | // Use built-in launcher state if enabled, otherwise use external control
934 | const isOpen = builtInLauncher ? launcherOpen : softPhoneOpen;
935 |
936 | return (
937 |
938 | {/* Built-in Launcher Button */}
939 | {builtInLauncher && (
940 |
947 | {isOpen ? : }
948 |
949 | )}
950 |
951 |
956 |
962 | {/* Header */}
963 |
964 | Softphone
965 | builtInLauncher ? setLauncherOpen(false) : setSoftPhoneOpen(false)}
967 | data-testid="hide-soft-phone-button"
968 | size="large"
969 | sx={{
970 | m: 0.5,
971 | '&:hover': {
972 | backgroundColor: 'rgba(0, 0, 0, 0.04)'
973 | }
974 | }}
975 | >
976 |
977 |
978 |
979 |
980 |
981 |
982 | {notificationState.message}
983 |
984 |
985 |
986 |
987 |
988 | {/* Main content - Scrollable area */}
989 |
995 | {/* Call Queue Block */}
996 |
1001 |
1002 | {/* Swipe Carusel */}
1003 |
1009 |
1010 | {/* Main Phone */}
1011 | theme.spacing(2),
1014 | backgroundColor: theme => theme.palette.mode === 'dark'
1015 | ? theme.palette.background.paper
1016 | : theme.palette.background.paper,
1017 | boxShadow: theme => theme.palette.mode === 'dark'
1018 | ? '0 4px 8px rgba(0, 0, 0, 0.3)'
1019 | : '0 2px 6px rgba(0, 0, 0, 0.1)',
1020 | mb: 2
1021 | }}
1022 | >
1023 | {/* Dial number input with icon */}
1024 | dialNumberOnEnter(event)}
1030 | onChange={handleDialStateChange}
1031 | variant="outlined"
1032 | InputProps={{
1033 | startAdornment: (
1034 |
1035 |
1036 |
1037 | ),
1038 | endAdornment: dialState && (
1039 |
1040 | setDialState('')}
1043 | edge="end"
1044 | aria-label="clear number"
1045 | >
1046 |
1047 |
1048 |
1049 | )
1050 | }}
1051 | />
1052 |
1053 | {/* Dial Keypad with improved spacing */}
1054 |
1055 |
1069 |
1070 |
1071 |
1072 |
1073 | {/* Swipe Carusel Body - Scrollable section */}
1074 |
1077 |
1088 |
1089 |
1090 |
1091 |
1092 |
1093 | {/* Status Block - Fixed at bottom */}
1094 |
1095 |
1099 |
1100 |
1101 |
1102 |
1103 |
1106 |
1109 |
1110 | );
1111 | }
1112 |
1113 |
1114 | export default SoftPhone;
1115 |
--------------------------------------------------------------------------------