From 4b96958bb474097d9e219ea3952f5fc484488397 Mon Sep 17 00:00:00 2001 From: DavidGOrtega Date: Sun, 19 Nov 2023 21:21:08 +0100 Subject: [PATCH] Chore UI improvements select (#11) * UI Improvements * cleanup EUI * more style * revert * remove css * remove outline * cleanup screen * Select * list adjustments * disabled states * disabled and numbers style * context fix and login * webhooks red trashcan * session * changes ui * wip * whisp * wip * wip * wip * almost * icons * pre * pre-release * remove unused * remove comment block * remove console --- README.md | 2 +- packages/008/.gitignore | 4 + packages/008/package.json | 4 +- packages/008/src/008Q.js | 70 +++- packages/008/src/008QWorker.js | 33 ++ packages/008/src/Events.js | 4 +- packages/008/src/components/Avatars.jsx | 31 -- packages/008/src/components/Basics.jsx | 274 +++++++++---- packages/008/src/components/Container.jsx | 3 +- packages/008/src/components/Container.web.jsx | 6 +- packages/008/src/components/Dialer.jsx | 148 ++++--- packages/008/src/components/Icons.jsx | 8 +- packages/008/src/components/Lists.jsx | 366 +++++++----------- .../008/src/components/Phone/Components.jsx | 190 +++------ packages/008/src/components/Phone/index.js | 205 +++++----- packages/008/src/components/Timer.jsx | 4 +- packages/008/src/screens/LoginScreen.jsx | 3 +- packages/008/src/screens/Screen.jsx | 23 +- packages/008/src/screens/SessionScreen.jsx | 232 ++++++----- packages/008/src/screens/SettingsScreen.jsx | 94 +++-- packages/008/src/store/Contacts.js | 1 - packages/008/src/store/Context.js | 18 +- packages/008/src/utils.js | 18 +- packages/008/stories/App.stories.js | 28 -- packages/008/stories/Dialer.stories.js | 2 +- packages/008/stories/LoginScreen.stories.js | 22 -- packages/008/stories/Phone.stories.js.nop | 104 ----- packages/008/stories/PhoneScreen.stories.js | 11 - packages/008/stories/Screen.stories.js | 22 +- packages/008/stories/SessionScreen.stories.js | 219 +++++------ packages/008desktop/index.js | 57 --- yarn.lock | 20 +- 32 files changed, 1103 insertions(+), 1123 deletions(-) create mode 100755 packages/008/src/008QWorker.js delete mode 100644 packages/008/stories/LoginScreen.stories.js delete mode 100644 packages/008/stories/Phone.stories.js.nop delete mode 100644 packages/008/stories/PhoneScreen.stories.js diff --git a/README.md b/README.md index 95b4c4b..4f0cf75 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,7 @@ We offer a commercial version that incorporates embedded AI models and provides | Mobile Softphone | sources | :green_circle: | | Events | :green_circle: | :green_circle: | | Integrations | :red_circle: | :green_circle: | -| AI Speech2Text | `english` | `multilanguage` | +| AI Speech2Text | :green_circle: | :green_circle: | | AI Summarization | :red_circle: | :green_circle: | | AI Sentiment Analysis | :red_circle: | :green_circle: | | AI KPI insights | :red_circle: | :green_circle: | diff --git a/packages/008/.gitignore b/packages/008/.gitignore index 426493f..656f852 100644 --- a/packages/008/.gitignore +++ b/packages/008/.gitignore @@ -43,3 +43,7 @@ yarn-error.* # @end @expo/electron-adapter /web/config*.json +/web/*.webm +/web/*.wav +/web/*.ogg +/web/*.mp3 diff --git a/packages/008/package.json b/packages/008/package.json index 43ebbb5..68cc7b3 100644 --- a/packages/008/package.json +++ b/packages/008/package.json @@ -25,7 +25,7 @@ "@expo/webpack-config": "^18.0.1", "@microsoft/applicationinsights-react-js": "^17.0.1", "@microsoft/applicationinsights-web": "^3.0.3", - "@react-native-picker/picker": "^2.5.1", + "audiobuffer-to-wav": "^1.0.0", "base-64": "^1.0.0", "crypto-js": "^4.1.1", "elasticlunr": "^0.9.5", @@ -42,9 +42,11 @@ "react-dom": "18.2.0", "react-icons": "^3.10.0", "react-native": "0.71.8", + "react-native-select-dropdown": "^3.4.0", "react-native-web": "^0.19.8", "sip.js": "^0.15.11", "vcf": "^2.1.1", + "whisper-webgpu": "^0.8.0", "zustand": "^4.4.1" }, "devDependencies": { diff --git a/packages/008/src/008Q.js b/packages/008/src/008Q.js index ea55551..82d45ea 100644 --- a/packages/008/src/008Q.js +++ b/packages/008/src/008Q.js @@ -1,9 +1,67 @@ -import { request } from './utils'; +import * as whisper from 'whisper-webgpu'; +import toWav from 'audiobuffer-to-wav'; -export const tts = async ({ audio: body }) => { - const port = 13003; - const endpoint = `http://localhost:${port}/transcribe`; - const transcript = await request({ endpoint, body }); +const CACHE = {}; +const S3Q = 'https://kunziteq.s3.gra.perf.cloud.ovh.net'; - return transcript; +export const wavBytes = async ({ chunks }) => { + // TODO: flatten 2 channels + let arrayBuffer = await chunks[0].arrayBuffer(); + const audioContext = new AudioContext(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + const wavBlob = new Blob([toWav(audioBuffer)], { type: 'audio/wav' }); + + arrayBuffer = await wavBlob.arrayBuffer(); + + return new Uint8Array(arrayBuffer); +}; + +export const ttsInfer = async ({ + chunks, + url, + audio = [], + bin = `${S3Q}/ttsb.bin`, + data = `${S3Q}/tts.json` +}) => { + const fetchBytes = async url => { + if (!CACHE[url]) { + const response = await fetch(url); + const buffer = await response.arrayBuffer(); + const bytes = new Uint8Array(buffer); + + CACHE[url] = bytes; + } + + return CACHE[url]; + }; + + const tokenizer = await fetchBytes(data); + const model = await fetchBytes(bin); + + if (url) audio = await fetchBytes(url); + if (chunks) audio = await wavBytes({ chunks }); + + await whisper.default(); + const builder = new whisper.SessionBuilder(); + const session = await builder.setModel(model).setTokenizer(tokenizer).build(); + + const { segments } = await session.run(audio); + + session.free(); + + return segments; +}; + +export const tts = async ({ audio }) => { + const remote = (await ttsInfer({ audio: audio.remote })).map(item => ({ + ...item, + channel: 'remote' + })); + const local = (await ttsInfer({ audio: audio.local })).map(item => ({ + ...item, + channel: 'local' + })); + const merged = [...remote, ...local].sort((a, b) => a.start - b.start); + + return merged; }; diff --git a/packages/008/src/008QWorker.js b/packages/008/src/008QWorker.js new file mode 100755 index 0000000..9b5545b --- /dev/null +++ b/packages/008/src/008QWorker.js @@ -0,0 +1,33 @@ +import { tts } from './008Q'; + +let BUSY = false; +const QUEUE = []; + +const process = async () => { + console.log('[008Q] Processing...'); + if (BUSY || !QUEUE.length) return; + + try { + BUSY = true; + + const [data] = QUEUE; + const { id, audio } = data; + + console.log('[008Q] Transcribing...'); + const transcript = await tts({ audio }); + + self.postMessage({ id, transcript }); + } catch (err) { + console.error(err); + } finally { + QUEUE.shift(); + BUSY = false; + process(); + } +}; + +self.addEventListener('message', async ({ data }) => { + console.log(`[008Q] Queuing job ${data.id}`); + QUEUE.push(data); + process(); +}); diff --git a/packages/008/src/Events.js b/packages/008/src/Events.js index 31abf15..b432d59 100644 --- a/packages/008/src/Events.js +++ b/packages/008/src/Events.js @@ -1,7 +1,6 @@ -import PQueue from 'p-queue'; - import { Platform } from 'react-native'; +import PQueue from 'p-queue'; import _ from 'lodash'; import { useStore } from './store/Context'; @@ -42,6 +41,7 @@ export const emit = async ({ type, data: payload }) => { const data = { ...payload, context }; + console.error(type, data); document?.dispatchEvent?.(new CustomEvent(type, { detail: data })); window?.parent?.postMessage?.({ type, data }, '*'); WEBHOOKS.addJob({ event: { type, data } }); diff --git a/packages/008/src/components/Avatars.jsx b/packages/008/src/components/Avatars.jsx index fa97f0b..f9c22b5 100644 --- a/packages/008/src/components/Avatars.jsx +++ b/packages/008/src/components/Avatars.jsx @@ -1,36 +1,5 @@ -import { View } from 'react-native'; - import { Avatar } from './Basics'; -export const Status = ({ color, size = 10, style }) => { - const roundstyle = { - width: size, - height: size, - borderRadius: size / 2, - backgroundColor: color || 'black' - }; - - return ; -}; - -export const UserAvatar = ({ - color, - avatar, - size = 35, - defaultImageUrl = 'avatar.png' -}) => { - return ( - - - - - ); -}; - export const ContactAvatar = ({ contact = {}, size = 35, diff --git a/packages/008/src/components/Basics.jsx b/packages/008/src/components/Basics.jsx index da9734d..551fddd 100644 --- a/packages/008/src/components/Basics.jsx +++ b/packages/008/src/components/Basics.jsx @@ -8,8 +8,6 @@ import { View, } from 'react-native'; -import { Picker } from '@react-native-picker/picker'; - import { CheckIcon, ClockIcon, @@ -28,27 +26,42 @@ import { TrashIcon, Share2Icon, PlusIcon, - VideoIcon + VideoIcon, + ChevronIcon, + PhoneOffIcon, + PhoneIncomingIcon, + PhoneOutgoingIcon, + SearchIcon, + EyeIcon } from './Icons'; +import SelectDropdown from 'react-native-select-dropdown'; + const fontFamily = 'Roboto Flex'; -const BORDERCOLOR = '#E2E6F0'; -const BACKCOLOR = '#fbfcfd'; -const COLORS = { - primary: '#0061a6', +export const COLORS = { + primary: '#2D69AF', warning: '#fec514', - danger: '#b4251d', - success: '#00726b', - secondary: '#00726b' + danger: '#C41818', + success: '#4DC418', + secondary: '#4DC418', + borderColor: '#E2E6F0', + backColor: '#F7F7F7', + app: '#ffffff', + textPrimary: '#313131', + textSecondary: '#6C6C6C' } +export const BORDERCOLOR = COLORS.borderColor; +export const BACKCOLOR = COLORS.backColor; + const defaultStyle = { - padding: 8, - backgroundColor: BACKCOLOR, + padding: 10, + height: 40, borderColor: BORDERCOLOR, borderWidth: 1, - fontFamily + fontFamily, + borderRadius: 5 }; export const Text = ({ children, style, ...props }) => ( @@ -58,7 +71,9 @@ export const Text = ({ children, style, ...props }) => ( ) export const TextInput = ({ style, ...props }) => - + export const TextField = ({ onChange, @@ -79,17 +94,46 @@ export const TextField = ({ ); }; -export const Select = ({ value, options, onChange, style, ...props }) => { +export const Select = ({ + value, + options, + onChange, + buttonStyle, + buttonTextStyle, + renderCustomizedButtonChild, + rowStyle, + rowTextStyle, + renderCustomizedRowChild, + dropdownStyle, + renderDropdownIcon, + iconStyle + }) => { return ( - - {options.map(({ label, text, value }) => )} - + item.value === value ) || 0} + onSelect={(item) => onChange?.(item.value)} + buttonTextAfterSelection={({ label, text }) => label || text} + rowTextForSelection={({ label, text }) => label || text} + buttonStyle={{ + // flex: 1, + height: 40, + width: '100%', + backgroundColor: COLORS.app, + borderRadius: 8, + borderWidth: 1, + borderColor: BORDERCOLOR, + ...buttonStyle + }} + buttonTextStyle={{ color: '#000', textAlign: 'left', fontSize: 14, fontFamily, ...buttonTextStyle }} + renderCustomizedButtonChild={renderCustomizedButtonChild} + rowStyle={{ padding: 5, paddingVertical: 10, borderBottomColor: BORDERCOLOR, ...rowStyle }} + rowTextStyle={{ textAlign: 'left', fontSize: 14, fontFamily, ...rowTextStyle }} + renderCustomizedRowChild={renderCustomizedRowChild} + dropdownStyle={{ height: options.length * 38, ...dropdownStyle }} + renderDropdownIcon={(opened) => renderDropdownIcon ? renderDropdownIcon(opened) : } + /> + ) } @@ -109,30 +153,33 @@ export const Button = ({ children, color, style, onClick, fullWidth }) => { ) }; +export const Icon = ({ icon, size, color = COLORS.textPrimary }) => { + const styling = { size, color: COLORS[color] || color } -export const ButtonIcon = ({ children, icon, iconType, onClick, style, size = 18, color = 'black' }) => { - const styling = { size, color } - const Icon = () => { - if (icon === 'phoneForwarded') return ; - if (icon === 'micOff') return ; - if (icon === 'play') return ; - if (icon === 'pause') return ; - if (icon === 'grid') return ; - if (icon === 'clock') return ; - if (icon === 'users') return ; - if (icon === 'user') return ; - if (icon === 'settings') return ; - if (icon === 'headphones') return ; - if (icon === 'phone') return ; - if (icon === 'delete') return ; - if (icon === 'trash') return ; - if (icon === 'share2') return ; - if (icon === 'plus') return ; - if (icon === 'video') return ; - if (icon === 'x') return ; - - return iconType; - } + if (icon === 'phoneForwarded') return ; + if (icon === 'hang') return ; + if (icon === 'micOff') return ; + if (icon === 'play') return ; + if (icon === 'pause') return ; + if (icon === 'grid') return ; + if (icon === 'clock') return ; + if (icon === 'users') return ; + if (icon === 'user') return ; + if (icon === 'settings') return ; + if (icon === 'headphones') return ; + if (icon === 'phone') return ; + if (icon === 'delete') return ; + if (icon === 'trash') return ; + if (icon === 'share2') return ; + if (icon === 'plus') return ; + if (icon === 'video') return ; + if (icon === 'check') return ; + if (icon === 'x') return ; + if (icon === 'eye') return ; + if (icon === 'search') return ; +} + +export const ButtonIcon = ({ children, icon, onClick, style, size = 18, color }) => { return ( - + { icon && } {children} ) }; +export const RoundIconButton = ({ size = 30, color, icon, iconSize, iconColor, onClick, style }) => { + return ( + + ) +} + export const Link = ({ children, style = {}, onClick }) => ( onClick?.()}> - + {children} @@ -183,8 +247,6 @@ export const CancelAccept = ({ onCancel, onAccept }) => { return ( { }} > {onCancel && ( - )} {onAccept && ( - )} @@ -206,18 +268,94 @@ export const CancelAccept = ({ onCancel, onAccept }) => { ); }; -export const Avatar = ({ imageUrl, name, size = 35 }) => ( - - - {imageUrl ? ( - - ) : ( - {name?.[0]} - )} - -); +export const CancelAcceptCall = ({ onCancel, onAccept }) => { + const size = 50; + return ( + + {onCancel && ( + + )} + + {onAccept && ( + + )} + + ); +}; + +export const Avatar = ({ imageUrl, name = '', size = 35 }) => { + const letters = name.split(/\s+/g).map(chunk => chunk[0]).join('').substring(0, 3).toUpperCase(); + + const stringToHslColor = (str, s, l) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + + const h = hash % 360; + return 'hsl('+h+', '+s+'%, '+l+'%)'; + } + + return ( + + + {imageUrl || !name.length ? ( + + ) : ( + + {letters} + + )} + + ) +}; + +export const Status = ({ color, size = 10, style }) => { + const roundstyle = { + width: size, + height: size, + borderRadius: size / 2, + backgroundColor: color || 'black' + }; + + return ; +}; + +export const CallIcon = ({ + call, + ...props +}) => { + const { direction } = call; + const color = props.color ? props.color : call.status === 'answered' ? COLORS.textSecondary : COLORS.danger + if (direction === 'inbound') + return ; + + return ; +}; diff --git a/packages/008/src/components/Container.jsx b/packages/008/src/components/Container.jsx index b423c12..afb9979 100644 --- a/packages/008/src/components/Container.jsx +++ b/packages/008/src/components/Container.jsx @@ -1,7 +1,8 @@ import { View } from 'react-native'; +import { COLORS } from './Basics'; export const Container = ({ children }) => ( - + {children} ); diff --git a/packages/008/src/components/Container.web.jsx b/packages/008/src/components/Container.web.jsx index 37a15a7..bbc1bb0 100644 --- a/packages/008/src/components/Container.web.jsx +++ b/packages/008/src/components/Container.web.jsx @@ -1,6 +1,8 @@ import { View } from 'react-native'; import { init } from '../Electron'; +import { COLORS } from './Basics'; + import { init as initEvents } from '../Events'; import { useStore } from '../store/Context'; @@ -11,15 +13,17 @@ export const Container = ({ children }) => { const { size: { width, height } } = useStore(); + return ( - + {children} diff --git a/packages/008/src/components/Dialer.jsx b/packages/008/src/components/Dialer.jsx index 184fb85..e18c426 100644 --- a/packages/008/src/components/Dialer.jsx +++ b/packages/008/src/components/Dialer.jsx @@ -1,9 +1,8 @@ import { useState, useEffect } from 'react'; import { TouchableOpacity, View } from 'react-native'; -import { Button, ButtonIcon, Text, TextInput } from './Basics'; -import { PhoneForwardedIcon, PhoneIcon, VideoIcon } from './Icons'; import Sound from '../Sound'; +import { ButtonIcon, COLORS, RoundIconButton, Text, TextInput } from './Basics'; import { CdrsList, ContactsList } from './Lists'; const keys = [ @@ -27,18 +26,17 @@ keys.forEach(async ({ keypad }) => { tones[keypad] = new Sound({ media: `dtmf/dtmf-${key}` }); }); -const DialButton = ({ keypad, sub, onPress, onLongPress }) => { +const DialButton = ({ style, item, onPress, onLongPress }) => { + const { keypad, sub } = item; return ( { tones[keypad].play?.(); onPress?.(keypad); @@ -47,38 +45,28 @@ const DialButton = ({ keypad, sub, onPress, onLongPress }) => { if (sub.length === 1) onLongPress?.(sub); }} > - - {keypad} - - - - {sub} - + {keypad} + {sub} ); }; -export const DialGrid = props => ( +export const DialGrid = ({ style, buttonStyle, ...events }) => ( {keys.map(item => ( - + ))} ); -export const DialPad = ({ number = '', onClick, onClickVideo, isTransfer }) => { +export const DialPad = ({ number = '', onClick, onClickVideo, isTransfer, style }) => { const [value, setValue] = useState(number); useEffect(() => { @@ -87,39 +75,65 @@ export const DialPad = ({ number = '', onClick, onClickVideo, isTransfer }) => { const onPressDialer = data => setValue(value + data); const onPressDelete = () => setValue(value.substring(0, value.length - 1)); + + const iconSize = 20; + const buttonCallSize = 60; + const cameraCallSize = 50; + const appColor = COLORS.app; return ( - - - - setValue(text)} - /> - - - + + + setValue(text)} + /> + - - - - {onClickVideo && - - - - } + + + {!isTransfer && + + onClick?.(value)} + /> + + {onClickVideo && + + onClickVideo?.(value)} + /> + + } + + } + + {isTransfer && + onClick?.(value)} + /> + } + ); @@ -141,7 +155,9 @@ export const Dialer = ({ contacts = {}, onContactClick, - onContactsFilterChange + onContactsFilterChange, + style, + isTransfer }) => { const [tab, setTab] = useState('dialer'); const [contactsFilter, setContactsFilter] = useState(''); @@ -152,20 +168,28 @@ export const Dialer = ({ }; return ( - + {tab === 'dialer' && ( - + number={number} + isTransfer={isTransfer} + /> )} {tab === 'cdrs' && ( - onCdrClick?.(number, video)} /> + onCdrClick?.(number, video)} + /> )} {tab === 'contacts' && ( {tabs.map(({ id, icon }) => ( - setTab(id)} /> + setTab(id)} + /> ))} diff --git a/packages/008/src/components/Icons.jsx b/packages/008/src/components/Icons.jsx index 74773d9..151d992 100644 --- a/packages/008/src/components/Icons.jsx +++ b/packages/008/src/components/Icons.jsx @@ -25,6 +25,9 @@ import { FiShare2 as Share2Icon, FiPlus as PlusIcon, FiVideo as VideoIcon, + FiChevronDown as ChevronIcon, + FiEyeOff as EyeIcon, + FiSearch as SearchIcon, } from 'react-icons/fi'; export const Icon = ({ icon, size = 16 }) => { @@ -61,5 +64,8 @@ export { TrashIcon, Share2Icon, PlusIcon, - VideoIcon + VideoIcon, + ChevronIcon, + EyeIcon, + SearchIcon, }; diff --git a/packages/008/src/components/Lists.jsx b/packages/008/src/components/Lists.jsx index 974106f..f617da4 100644 --- a/packages/008/src/components/Lists.jsx +++ b/packages/008/src/components/Lists.jsx @@ -1,251 +1,175 @@ import moment from 'moment'; import React from 'react'; -import { FiEyeOff, FiFile, FiSearch } from 'react-icons/fi'; import { - ActivityIndicator, View, - TextInput, TouchableOpacity, FlatList } from 'react-native'; import { ContactAvatar } from './Avatars'; -import { ButtonIcon, Text } from './Basics'; -import { CallIcon } from './Phone/Components'; +import { BORDERCOLOR, ButtonIcon, COLORS, CallIcon, Icon, Text, TextInput } from './Basics'; import { VideoIcon } from './Icons'; -class NothingToShow extends React.Component { - render() { - return ( - - - - ); - } -} - -class CdrCell extends React.Component { - render = () => { - const { cdr = {}, onClick, lang = 'en' } = this.props; - const { direction, from, to, date, contact = {}, video } = cdr; - const destination = direction === 'inbound' ? from : to; - - const { name } = contact; - const displayclient = name ? `${name} (${destination})` : destination; - - const callDate = moment(date).locale(lang).calendar({ - sameDay: 'LT', - lastDay: `[${moment(date).calendar().split(' ')[0]}]`, - lastWeek: 'dddd' - }); - - return ( - onClick?.(destination, video)} - > - - - +const PADDING = 10; +const CELL_HEIGHT = 40; +const SMALLFONT = 12; - - - - - - - {displayclient} - - - - {callDate} - - +const SearchInput = ({ onChange, value, style }) => ( + + - {video && - - - - } - - ); - }; -} + onChange?.(text)} + value={value} + /> + +); -class ContactCell extends React.Component { - render() { - const { contact = {}, onClick } = this.props; - const { id, name } = contact; +const List = ({ + data = [], + renderItem, + onChangeFilter, + showFilter, + filterVal, + total, + maxItems = 500, + style +}) => ( + + {showFilter && ( + + )} + + {data?.length > 0 ? ( + item.id || item.name} + /> + ) : ( + + + + )} - const isLocal = id.startsWith('cvf-'); - return ( - onClick?.(contact)} + {data?.length > maxItems && ( + - - {isLocal && } - - - - - + + {maxItems} / {total || data.length} + + + )} + +) + +const CdrCell = ({ cdr = {}, onClick, lang = 'en' }) => { + const { direction, from, to, date, contact = {}, video } = cdr; + const destination = direction === 'inbound' ? from : to; + const { name } = contact; + const displayName = name? `${name || ''} (${destination})` : destination; + + const callDate = moment(date).locale(lang).calendar({ + sameDay: 'LT', + lastDay: `[${moment(date).calendar().split(' ')[0]}]`, + lastWeek: 'dddd' + }); + + const subcellStyle = { justifyContent: 'center', marginRight: PADDING } + return ( + onClick?.(destination, video)} + > + + + - - - {name} - - - - ); - } -} + + + {displayName} + -class WebhookCell extends React.Component { - render() { - const { webhook, onClick, onDeleteClick } = this.props; - const { label, endpoint } = webhook; + + {callDate} + + - return ( - onClick?.(webhook)} - > - - - {label} - - - - {endpoint} - + + + + + {video && + + - - onDeleteClick?.(webhook)} - /> - - ); - } + } + + ); } -const SearchInput = ({ onChange, value, style }) => ( - - - +const ContactCell = ({ contact = {}, onClick }) => ( + onClick?.(contact)} + > + + + + {contact?.name || contact?.phones?.[0]} + +) - onChange?.(text)} - value={value} - /> - -); +const WebhookCell = ({ webhook, onClick, onDeleteClick }) => { + const { label, endpoint } = webhook; -class List extends React.Component { - state = {}; - - render_cell({ item }) { - throw new Error('Not yet implemented'); - } - - render() { - const { - data = [], - showFilter, - onChangeFilter, - filterVal, - maxItems = 500, - total, - loading - } = this.props; - - return ( - - {showFilter && ( - - )} - - {data?.length > 0 && ( - item.id || item.name} - contentContainerStyle={{ paddingTop: 10, paddingLeft: 5 }} - /> - )} - - {!data?.length && } - - - {loading && } - - {data?.length > maxItems && ( - - {maxItems} / {total || data.length} - - )} - + return ( + onClick?.(webhook)} + > + + {label} + + {endpoint} - ); - } -} -export class CdrsList extends List { - render_cell({ item: cdr }) { - const { onClick } = this.props; - return ; - } + onDeleteClick?.(webhook)} + /> + + ); } -export class ContactsList extends List { - render_cell({ item: contact }) { - const { onClick } = this.props; - return ; - } -} +export const CdrsList = (props) => ( + + } /> +) -export class WebhooksList extends List { - render_cell({ item: webhook }) { - const { onClick, onDeleteClick } = this.props; - return ( - - ); - } -} +export const ContactsList = (props) => ( + + } /> +) + +export const WebhooksList = (props) => ( + + } /> +) diff --git a/packages/008/src/components/Phone/Components.jsx b/packages/008/src/components/Phone/Components.jsx index 043f4c9..ac98212 100644 --- a/packages/008/src/components/Phone/Components.jsx +++ b/packages/008/src/components/Phone/Components.jsx @@ -1,143 +1,79 @@ import { TouchableOpacity, View } from 'react-native'; -import { ContactAvatar, UserAvatar } from '../Avatars'; -import { ButtonIcon, Link, Select, Text } from '../Basics'; -import { FormRow } from '../Forms'; -import { PhoneIncomingIcon, PhoneOutgoingIcon } from '../Icons'; -import Timer from '../Timer'; +import { Avatar, COLORS, Select, Status, Text } from '../Basics'; -export const Numbers = ({ numbers, number, onChange }) => { - const options = numbers.map(({ number, tags = [] }) => { - return { - label: `${number} ${tags.join(' ')}`, - value: number - }; - }); - - const needle = number || numbers[0]?.number; - const selected = options.find(({ value }) => value === needle); - - if (!numbers?.length) return null; - - return ( - + + {name} + {item?.value} + + } + rowTextStyle={{ ...itemFontStyle, textAlign: 'center', backgroundColor, borderRadius: 8, padding: 5 }} + dropdownStyle={{ margin: 10, height: options.length * 50, backgroundColor: '#fff', borderRadius: 8 }} + // renderDropdownIcon={() => {}} + /> + + } + - -); + ) +} diff --git a/packages/008/src/components/Phone/index.js b/packages/008/src/components/Phone/index.js index cca0f16..38b2bc8 100644 --- a/packages/008/src/components/Phone/index.js +++ b/packages/008/src/components/Phone/index.js @@ -20,7 +20,7 @@ import { Cdr } from '../../store/Cdr'; import { Context, useStore } from '../../store/Context'; import { emit } from '../../Events'; import { cleanPhoneNumber, sleep, genId, blobToDataURL } from '../../utils'; -import { tts } from '../../008Q'; +import { tts, wavBytes } from '../../008Q'; import { name as packageName } from '../../../package.json'; @@ -45,6 +45,14 @@ class Phone extends React.Component { ...props }; + + this.qworker = new Worker(new URL('../../008QWorker.js', import.meta.url), { + type: 'module' + }); + + this.qworker.addEventListener('message', ({ data }) => { + this.emit({ type: 'phone:transcript', data }); + }); } emit = ({ type, data = {} }) => { @@ -180,7 +188,7 @@ class Phone extends React.Component { }); ua.on('invite', async session => { - if (this.state.session) { + if (this.state.session?.id) { session.terminate(); return; } @@ -247,9 +255,13 @@ class Phone extends React.Component { }; hangup = async () => { - const { session } = this.state; - if (session?.endTime) return; - session.terminate(); + try { + const { session } = this.state; + if (session?.endTime) return; + session.terminate(); + } catch (err) { + this.reset(); + } }; call = async (opts = {}) => { @@ -306,64 +318,79 @@ class Phone extends React.Component { }; transfer = (opts = {}) => { - const { session, dialer_number, show_blindTransfer, microphone } = - this.state; + try { + const { + session, + dialer_number, + show_blindTransfer, + microphone, + number_out + } = this.state; - const { - number = dialer_number, - blind = show_blindTransfer, - extraHeaders = [], - video = false - } = opts; - - const target = cleanPhoneNumber(number); - const payload = { - extraHeaders, - sessionDescriptionHandlerOptions: { - constraints: { - audio: { deviceId: { ideal: microphone } }, - video + const { + number = dialer_number, + blind = show_blindTransfer, + extraHeaders = [], + video = false + } = opts; + + const indentityHeaders = number_out + ? [`P-Asserted-Identity:${number_out}`, `x-Number:${number_out}`] + : []; + + const target = cleanPhoneNumber(number); + const payload = { + extraHeaders: [...indentityHeaders, ...extraHeaders], + sessionDescriptionHandlerOptions: { + constraints: { + audio: { deviceId: { ideal: microphone } }, + video + } } - } - }; + }; - if (blind) { - session.refer(target, payload); - return; - } + if (blind) { + session.refer(target, payload); + return; + } - const sessiont = this.ua.invite(target, payload); + const sessiont = this.ua.invite(target, payload); - const cdr = new Cdr({ session: sessiont }); - cdr.setContact(this.context.contacts().contact_by_phone({ phone: cdr.to })); - sessiont.cdr = cdr; + const cdr = new Cdr({ session: sessiont }); + cdr.setContact( + this.context.contacts().contact_by_phone({ phone: cdr.to }) + ); + sessiont.cdr = cdr; - sessiont.on('progress', () => { - RING_BACK.play(); - }); + sessiont.on('progress', () => { + RING_BACK.play(); + }); - sessiont.on('accepted', () => { - RING_BACK.stop(); - this.setState({ rand: genId() }); - }); + sessiont.on('accepted', () => { + RING_BACK.stop(); + this.setState({ rand: genId() }); + }); - sessiont.on('terminated', (_, cause) => { - RING_BACK.stop(); + sessiont.on('terminated', (_, cause) => { + RING_BACK.stop(); - this.setState({ sessiont: null }, () => { - try { - session?.unhold(); - } catch (err) { - this.reset(cause); - } + this.setState({ sessiont: null }, () => { + try { + session?.unhold(); + } catch (err) { + this.reset(cause); + } + }); }); - }); - sessiont.on('failed', message => { - play_failure(message); - }); + sessiont.on('failed', message => { + play_failure(message); + }); - this.setState({ sessiont }); + this.setState({ sessiont }); + } catch (err) { + play_failure(); + } }; reset = cause => { @@ -382,6 +409,8 @@ class Phone extends React.Component { }; processRecording = ({ session }) => { + const type = 'audio/webm'; + const chunksBlob = chunks => { if (!chunks.length) return; @@ -391,8 +420,6 @@ class Phone extends React.Component { const streamIn = new MediaStream(); const streamOut = new MediaStream(); - const type = 'audio/webm'; - let recorder; const chunks = []; @@ -414,7 +441,7 @@ class Phone extends React.Component { const src = audioContext.createMediaStreamSource(stream); src.connect(multi); - recorder = new MediaRecorder(stream, { mimeType: type }); + recorder = new MediaRecorder(stream); recorder.ondataavailable = ({ data }) => chunks.push(data); recorder.start(); }); @@ -429,21 +456,13 @@ class Phone extends React.Component { const blob = await chunksBlob(chunks); this.emit({ type: 'phone:recording', data: { audio: { id, blob } } }); - try { - const { segments } = await tts({ - audio: { - remote: await chunksBlob(chunksIn), - local: await chunksBlob(chunksOut) - } - }); - - this.emit({ - type: 'phone:transcript', - data: { transcript: { id, segments } } - }); - } catch (err) { - console.error(err); - } + this.qworker.postMessage({ + id, + audio: { + remote: await wavBytes({ chunks: chunksIn }), + local: await wavBytes({ chunks: chunksOut }) + } + }); }; recorder.start(); @@ -495,6 +514,7 @@ class Phone extends React.Component { sipUser, sipPassword, + nickname, avatar, allowAutoanswer, @@ -522,6 +542,8 @@ class Phone extends React.Component { sipUri, sipUser, sipPassword, + + nickname, avatar, allowAutoanswer, @@ -559,11 +581,13 @@ class Phone extends React.Component { numbers = [], number_out, - speaker, - statuses = [], status, network, + + sipUser, + sipUri = '', + nickname, avatar, transferAllowed, @@ -579,18 +603,20 @@ class Phone extends React.Component { item => item.value === (noConnection ? 'offline' : status) )?.color; - const callHandler = (number, video) => { - this.call({ number, video }); + const callHandler = async (number, video) => { + await this.call({ number, video }); }; - const showTransferDialerHandler = (blind = false) => { - session?.hold(); + const showTransferDialerHandler = async (blind = false) => { + session?.hasAnswer && session?.hold(); this.setState({ show_transfer: true, show_blindTransfer: blind }); }; - const transferOnCancelHandler = () => { - this.setState({ show_transfer: false }, () => { - sessiont?.terminate(); + const transferOnCancelHandler = async () => { + this.setState({ show_transfer: false }, async () => { + try { + sessiont?.terminate(); + } catch (err) {} session?.unhold(); }); }; @@ -598,8 +624,8 @@ class Phone extends React.Component { const transferConfirmHandler = () => sessiont.refer(session); const onTransferHandler = number => { - this.setState({ show_transfer: false }, () => { - this.transfer({ number }); + this.setState({ show_transfer: false }, async () => { + await this.transfer({ number }); }); }; @@ -609,19 +635,24 @@ class Phone extends React.Component { const contactClickHandler = contact => this.emit({ type: 'contact:click', data: { contact } }); + const [sipUriName] = sipUri.replace('sip:', '').split('@'); + const displayName = nickname || sipUser || sipUriName || ''; + return ( - +
this.context.toggleShowSettings(true)} /> showTransferDialerHandler(false)} onBlindTransfer={() => showTransferDialerHandler(true)} onContactClick={contactClickHandler} @@ -657,18 +686,18 @@ class Phone extends React.Component { session={sessiont} visible={!_.isEmpty(sessiont)} onCancel={transferOnCancelHandler} - onAccept={sessiont?.hasAnswer && transferConfirmHandler} - transferAllowed={false} - blindTransferAllowed={false} + onAccept={transferConfirmHandler} + isTransfer={true} /> onTransferHandler(phones[0])} diff --git a/packages/008/src/components/Timer.jsx b/packages/008/src/components/Timer.jsx index 95cd5fd..dfddecc 100644 --- a/packages/008/src/components/Timer.jsx +++ b/packages/008/src/components/Timer.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Text } from 'react-native'; +import { Text } from './Basics'; const format = (opts = {}) => { const { date_from = new Date().getTime(), date_to = new Date().getTime() } = @@ -43,6 +43,6 @@ export default class Timer extends React.Component { render() { const { currentTime } = this.state; - return {currentTime}; + return {currentTime}; } } diff --git a/packages/008/src/screens/LoginScreen.jsx b/packages/008/src/screens/LoginScreen.jsx index 49774a8..ac49653 100644 --- a/packages/008/src/screens/LoginScreen.jsx +++ b/packages/008/src/screens/LoginScreen.jsx @@ -33,8 +33,9 @@ const LoginForm = ({ onSubmit, loading }) => {