├── .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 | 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 |
    141 | 142 | 151 | )} 152 | label={( 153 | 154 | {localStatePhone.connectOnStart ? : } 155 | Auto-Connect 156 | 157 | )} 158 | labelPlacement="start" 159 | /> 160 | 161 | 162 | 171 | )} 172 | label={( 173 | 174 | {localStatePhone.connectedPhone ? : } 175 | {localStatePhone.connectedPhone ? 'Connected' : 'Disconnected'} 176 | 177 | )} 178 | labelPlacement="start" 179 | /> 180 | 181 | 182 |
    183 |
    184 | 185 | 186 | Notifications 187 | 188 |
    189 | 190 | 199 | )} 200 | label={( 201 | 202 | {localStatePhone.notifications ? : } 203 | Notifications 204 | 205 | )} 206 | labelPlacement="start" 207 | /> 208 | 209 |
    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 | [![NPM](https://img.shields.io/npm/v/react-softphone.svg)](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 | ![React Softphone Interface](docs/images/softphone-interface.png) 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 |
    132 |
    133 |

    📞 My Softphone App

    134 |

    Click the button below to open the softphone

    135 | 136 | 151 | 152 | 186 |
    187 |
    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 | 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 | 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 | --------------------------------------------------------------------------------