From 5e927662fd4e18ce7ecabdf5845f28d08d227e32 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Fri, 16 Oct 2026 17:54:43 +0200 Subject: [PATCH 1/2] feat(calls): video call action for operators with a video device Add a video option to the call path (renderer hook, IPC, Phone Island page) that starts the call with the camera in the first INVITE through phone-island-call-start with video: true, and a video call icon next to the call icon for operators that have a device able to receive video (WebRTC, NethLink, mobile app or a known desk video phone). Desk phones refuse a video upgrade added later in the call. --- public/locales/en/translations.json | 1 + public/locales/it/translations.json | 1 + .../controllers/PhoneIslandController.ts | 4 +-- src/main/lib/ipcEvents.ts | 4 +-- .../BaseModule/ContactNameAndAction.tsx | 33 +++++++++++++++++-- .../src/hooks/usePhoneIslandEventHandler.ts | 4 +-- src/renderer/src/pages/PhoneIslandPage.tsx | 4 ++- src/shared/utils/utils.ts | 13 ++++++++ 8 files changed, 55 insertions(+), 9 deletions(-) diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 8ecbd30d..df50f8b2 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -302,6 +302,7 @@ "Attend transfer": "Attend transfer", "Call": "Call", "Open user devices": "Open user devices", + "Video call": "Video call", "Busy": "Busy", "Ringing": "Ringing" }, diff --git a/public/locales/it/translations.json b/public/locales/it/translations.json index 75e1efd7..5511681b 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -302,6 +302,7 @@ "Attend transfer": "Trasferimento consultativo", "Call": "Chiama", "Open user devices": "Apri dispositivi utente", + "Video call": "Videochiamata", "Busy": "Occupato", "Ringing": "Ringing" }, diff --git a/src/main/classes/controllers/PhoneIslandController.ts b/src/main/classes/controllers/PhoneIslandController.ts index 23332124..28947f9d 100644 --- a/src/main/classes/controllers/PhoneIslandController.ts +++ b/src/main/classes/controllers/PhoneIslandController.ts @@ -314,9 +314,9 @@ export class PhoneIslandController { }) } - call(number: string) { + call(number: string, video: boolean = false) { try { - this.window.emit(IPC_EVENTS.START_CALL, number) + this.window.emit(IPC_EVENTS.START_CALL, number, video) } catch (e) { Log.error(`Unable to call ${number}`) } diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index b25ffab9..9d0b7e32 100644 --- a/src/main/lib/ipcEvents.ts +++ b/src/main/lib/ipcEvents.ts @@ -137,8 +137,8 @@ export function registerIpcEvents() { return app.getLocale() }) - ipcMain.on(IPC_EVENTS.EMIT_START_CALL, async (_event, phoneNumber) => { - PhoneIslandController.instance.call(phoneNumber) + ipcMain.on(IPC_EVENTS.EMIT_START_CALL, async (_event, phoneNumber, video?: boolean) => { + PhoneIslandController.instance.call(phoneNumber, video) }) ipcMain.on(IPC_EVENTS.START_CALL_BY_URL, async (_event, url) => { diff --git a/src/renderer/src/components/Modules/NethVoice/BaseModule/ContactNameAndAction.tsx b/src/renderer/src/components/Modules/NethVoice/BaseModule/ContactNameAndAction.tsx index 343858fd..6e2925fc 100644 --- a/src/renderer/src/components/Modules/NethVoice/BaseModule/ContactNameAndAction.tsx +++ b/src/renderer/src/components/Modules/NethVoice/BaseModule/ContactNameAndAction.tsx @@ -1,16 +1,17 @@ import { NumberCaller } from '../../../NumberCaller' import { ContactType } from '@shared/types' -import { debouncer, isDev } from '@shared/utils/utils' +import { debouncer, isDev, isVideoCapableExtension } from '@shared/utils/utils' import { FavouriteStar } from './FavouritesStar' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { useAccount } from '@renderer/hooks/useAccount' -import { useNethlinkData } from '@renderer/store' +import { useNethlinkData, useSharedState } from '@renderer/store' import { usePhoneIslandEventHandler } from '@renderer/hooks/usePhoneIslandEventHandler' import { useIsTruncated } from '@renderer/hooks/useIsTruncated' import { Avatar } from '../../../Nethesis' import { CustomThemedTooltip } from '../../../Nethesis/CustomThemedTooltip' import { faPhone as CallIcon, + faVideo as VideoCallIcon, faBriefcase as CompanyIcon, } from '@fortawesome/free-solid-svg-icons' import { t } from 'i18next' @@ -56,6 +57,12 @@ export const ContactNameAndActions = ({ operatorPresence === 'online' || operatorPresence === 'available' const isCallDisabled = !isCallsEnabled || !isOperatorOnline + const [account] = useSharedState('account') + // Video calls start from the NethLink WebRTC device and need a video-capable device on the other side + const canVideoCall = + isOperator && + ['nethlink', 'webrtc'].includes(account?.data?.default_device?.type || '') && + (operators?.operators?.[username!]?.endpoints?.extension || []).some(isVideoCapableExtension) const isDisplayedNumberEmpty = displayedNumber === null || @@ -218,6 +225,28 @@ export const ContactNameAndActions = ({ }} /> )} + {!isHighlight && canVideoCall && ( + { + if (isCallDisabled) return + debouncer( + 'onVideoCallNumber', + () => { + callNumber(contact.speeddial_num!, { video: true }) + }, + 250, + ) + }} + /> + )}
{ ) const callNumber = useCallback( - (number: string) => { + (number: string, options: { video?: boolean } = {}) => { const numberCleanerRegex = /\s+/g const cleanNumber = `${number}`.replace(numberCleanerRegex, '') const isNumberValid = validatePhoneNumber(cleanNumber) if (number && isNumberValid && isCallsEnabled) - window.electron.send(IPC_EVENTS.EMIT_START_CALL, cleanNumber) + window.electron.send(IPC_EVENTS.EMIT_START_CALL, cleanNumber, Boolean(options.video)) else Log.warning('unable to call', { number, isCallsEnabled, isNumberValid }) }, diff --git a/src/renderer/src/pages/PhoneIslandPage.tsx b/src/renderer/src/pages/PhoneIslandPage.tsx index 01dd4ccb..81af4785 100644 --- a/src/renderer/src/pages/PhoneIslandPage.tsx +++ b/src/renderer/src/pages/PhoneIslandPage.tsx @@ -70,9 +70,11 @@ export function PhoneIslandPage() { } }) - window.electron.receive(IPC_EVENTS.START_CALL, (number: string) => { + window.electron.receive(IPC_EVENTS.START_CALL, (number: string, video?: boolean) => { + // video: true offers the camera in the first INVITE (desk video phones refuse a later upgrade) eventDispatch(PHONE_ISLAND_EVENTS['phone-island-call-start'], { number, + ...(video ? { video: true } : {}), }) }) diff --git a/src/shared/utils/utils.ts b/src/shared/utils/utils.ts index d22875b5..145755c9 100644 --- a/src/shared/utils/utils.ts +++ b/src/shared/utils/utils.ts @@ -60,3 +60,16 @@ export const isDevTools = getBoolEnvParam('DEVTOOLS') export const getAccountUID = (a: Account) => { return `${a.host}@${a.username}` } + +// Desk phones known to handle video calls, matched on the registered User-Agent +const VIDEO_PHONE_USER_AGENTS = /Yealink SIP-(T58|VP59)|Fanvil (V6|X7|i[0-9])|Nethesis NP-V|Akuvox|Grandstream GXV/i + +/** + * True when the extension can receive a video call: WebRTC, NethLink and the mobile app always, + * physical phones only for known video models + */ +export function isVideoCapableExtension(extension?: { type?: string; description?: string }) { + if (!extension) return false + if (['webrtc', 'nethlink', 'mobile'].includes(extension.type || '')) return true + return extension.type === 'physical' && VIDEO_PHONE_USER_AGENTS.test(extension.description || '') +} From dc18179313e9e2c3128eeb7f372c7e9e1063d4e9 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 21 Oct 2026 16:27:55 +0200 Subject: [PATCH 2/2] fix(lint): LoginSizes.SSO_STEP aliases HOST_STEP The two members share the same height and @typescript-eslint's no-duplicate-enum-values rejects the duplicated literal, the only lint error left on main. --- src/main/lib/ipcEvents.ts | 9 ++++++--- .../BaseModule/ContactNameAndAction.tsx | 8 ++++++-- .../components/Modules/NethVoice/Chat/icons.ts | 8 +++++++- .../src/hooks/usePhoneIslandEventHandler.ts | 6 +++++- src/renderer/src/pages/LoginPage.tsx | 2 +- src/renderer/src/pages/PhoneIslandPage.tsx | 17 ++++++++++------- src/shared/utils/utils.ts | 16 ++++++++++++---- 7 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index 9d0b7e32..e69cedc5 100644 --- a/src/main/lib/ipcEvents.ts +++ b/src/main/lib/ipcEvents.ts @@ -137,9 +137,12 @@ export function registerIpcEvents() { return app.getLocale() }) - ipcMain.on(IPC_EVENTS.EMIT_START_CALL, async (_event, phoneNumber, video?: boolean) => { - PhoneIslandController.instance.call(phoneNumber, video) - }) + ipcMain.on( + IPC_EVENTS.EMIT_START_CALL, + async (_event, phoneNumber, video?: boolean) => { + PhoneIslandController.instance.call(phoneNumber, video) + }, + ) ipcMain.on(IPC_EVENTS.START_CALL_BY_URL, async (_event, url) => { function triggerError(e, request: ClientRequest | undefined = undefined) { diff --git a/src/renderer/src/components/Modules/NethVoice/BaseModule/ContactNameAndAction.tsx b/src/renderer/src/components/Modules/NethVoice/BaseModule/ContactNameAndAction.tsx index 6e2925fc..c47f087c 100644 --- a/src/renderer/src/components/Modules/NethVoice/BaseModule/ContactNameAndAction.tsx +++ b/src/renderer/src/components/Modules/NethVoice/BaseModule/ContactNameAndAction.tsx @@ -61,8 +61,12 @@ export const ContactNameAndActions = ({ // Video calls start from the NethLink WebRTC device and need a video-capable device on the other side const canVideoCall = isOperator && - ['nethlink', 'webrtc'].includes(account?.data?.default_device?.type || '') && - (operators?.operators?.[username!]?.endpoints?.extension || []).some(isVideoCapableExtension) + ['nethlink', 'webrtc'].includes( + account?.data?.default_device?.type || '', + ) && + (operators?.operators?.[username!]?.endpoints?.extension || []).some( + isVideoCapableExtension, + ) const isDisplayedNumberEmpty = displayedNumber === null || diff --git a/src/renderer/src/components/Modules/NethVoice/Chat/icons.ts b/src/renderer/src/components/Modules/NethVoice/Chat/icons.ts index da12a543..8ad269ba 100644 --- a/src/renderer/src/components/Modules/NethVoice/Chat/icons.ts +++ b/src/renderer/src/components/Modules/NethVoice/Chat/icons.ts @@ -3,5 +3,11 @@ import type { IconDefinition } from '@fortawesome/fontawesome-svg-core' export const faNewChat = { prefix: 'fas', iconName: 'new-chat', - icon: [512, 512, [], '', 'M0 352L0 128C0 75 43 32 96 32l320 0c53 0 96 43 96 96l0 224c0 53-43 96-96 96l-120 0c-5.2 0-10.2 1.7-14.4 4.8L166.4 539.2c-4.2 3.1-9.2 4.8-14.4 4.8-13.3 0-24-10.7-24-24l0-72-32 0c-53 0-96-43-96-96zM232 152v64h-64v48h64v64h48v-64h64v-48h-64v-64z'], + icon: [ + 512, + 512, + [], + '', + 'M0 352L0 128C0 75 43 32 96 32l320 0c53 0 96 43 96 96l0 224c0 53-43 96-96 96l-120 0c-5.2 0-10.2 1.7-14.4 4.8L166.4 539.2c-4.2 3.1-9.2 4.8-14.4 4.8-13.3 0-24-10.7-24-24l0-72-32 0c-53 0-96-43-96-96zM232 152v64h-64v48h64v64h48v-64h64v-48h-64v-64z', + ], } as unknown as IconDefinition diff --git a/src/renderer/src/hooks/usePhoneIslandEventHandler.ts b/src/renderer/src/hooks/usePhoneIslandEventHandler.ts index cdc3f88d..b30935ee 100644 --- a/src/renderer/src/hooks/usePhoneIslandEventHandler.ts +++ b/src/renderer/src/hooks/usePhoneIslandEventHandler.ts @@ -37,7 +37,11 @@ export const usePhoneIslandEventHandler = () => { const cleanNumber = `${number}`.replace(numberCleanerRegex, '') const isNumberValid = validatePhoneNumber(cleanNumber) if (number && isNumberValid && isCallsEnabled) - window.electron.send(IPC_EVENTS.EMIT_START_CALL, cleanNumber, Boolean(options.video)) + window.electron.send( + IPC_EVENTS.EMIT_START_CALL, + cleanNumber, + Boolean(options.video), + ) else Log.warning('unable to call', { number, isCallsEnabled, isNumberValid }) }, diff --git a/src/renderer/src/pages/LoginPage.tsx b/src/renderer/src/pages/LoginPage.tsx index ffd2b23d..635af6bf 100644 --- a/src/renderer/src/pages/LoginPage.tsx +++ b/src/renderer/src/pages/LoginPage.tsx @@ -28,7 +28,7 @@ export interface LoginPageProps { enum LoginSizes { BASE = 550, HOST_STEP = 400, - SSO_STEP = 400, + SSO_STEP = HOST_STEP, ACCOUNT_FORM = 488, TWO_FACTOR_AUTH = 420, BACK_BUTTON = 60, diff --git a/src/renderer/src/pages/PhoneIslandPage.tsx b/src/renderer/src/pages/PhoneIslandPage.tsx index 81af4785..0a3e4c3b 100644 --- a/src/renderer/src/pages/PhoneIslandPage.tsx +++ b/src/renderer/src/pages/PhoneIslandPage.tsx @@ -70,13 +70,16 @@ export function PhoneIslandPage() { } }) - window.electron.receive(IPC_EVENTS.START_CALL, (number: string, video?: boolean) => { - // video: true offers the camera in the first INVITE (desk video phones refuse a later upgrade) - eventDispatch(PHONE_ISLAND_EVENTS['phone-island-call-start'], { - number, - ...(video ? { video: true } : {}), - }) - }) + window.electron.receive( + IPC_EVENTS.START_CALL, + (number: string, video?: boolean) => { + // video: true offers the camera in the first INVITE (desk video phones refuse a later upgrade) + eventDispatch(PHONE_ISLAND_EVENTS['phone-island-call-start'], { + number, + ...(video ? { video: true } : {}), + }) + }, + ) window.electron.receive(IPC_EVENTS.END_CALL, () => { eventDispatch(PHONE_ISLAND_EVENTS['phone-island-call-end']) diff --git a/src/shared/utils/utils.ts b/src/shared/utils/utils.ts index 145755c9..dd23ff53 100644 --- a/src/shared/utils/utils.ts +++ b/src/shared/utils/utils.ts @@ -62,14 +62,22 @@ export const getAccountUID = (a: Account) => { } // Desk phones known to handle video calls, matched on the registered User-Agent -const VIDEO_PHONE_USER_AGENTS = /Yealink SIP-(T58|VP59)|Fanvil (V6|X7|i[0-9])|Nethesis NP-V|Akuvox|Grandstream GXV/i +const VIDEO_PHONE_USER_AGENTS = + /Yealink SIP-(T58|VP59)|Fanvil (V6|X7|i[0-9])|Nethesis NP-V|Akuvox|Grandstream GXV/i /** * True when the extension can receive a video call: WebRTC, NethLink and the mobile app always, * physical phones only for known video models */ -export function isVideoCapableExtension(extension?: { type?: string; description?: string }) { +export function isVideoCapableExtension(extension?: { + type?: string + description?: string +}) { if (!extension) return false - if (['webrtc', 'nethlink', 'mobile'].includes(extension.type || '')) return true - return extension.type === 'physical' && VIDEO_PHONE_USER_AGENTS.test(extension.description || '') + if (['webrtc', 'nethlink', 'mobile'].includes(extension.type || '')) + return true + return ( + extension.type === 'physical' && + VIDEO_PHONE_USER_AGENTS.test(extension.description || '') + ) }