From f3bb039976ecf72722eaf35b69d013f3904c6a16 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Fri, 8 May 2026 18:43:29 -0300 Subject: [PATCH 01/55] feat: register callto:/tel: deep link handlers (DAMOVO-1) Register Rocket.Chat as OS handler for callto: and tel: URL schemes on Windows, macOS, and Linux. When a telephony link is clicked in any app, RC launches or focuses and dispatches a typed IPC event to the server webview with the parsed phone number. - Register callto/tel schemes in electron-builder.json (all platforms) - Add parseTelephonyLink() with number normalization and callto:// support - Add performTelephonyCall() with multi-server dialog + remember choice - Expose onTelephonyCallRequested callback on RocketChatDesktop API - Persist telephonyPreferredServer via selectPersistableValues - IPC listener registered before onReady to avoid cold-start race --- electron-builder.json | 9 ++-- src/app/main/app.ts | 10 ++++- src/app/selectors.ts | 2 + src/deepLinks/main.ts | 88 +++++++++++++++++++++++++++++++++++++- src/preload.ts | 3 ++ src/servers/preload/api.ts | 5 +++ src/store/actions.ts | 4 +- src/store/rootReducer.ts | 2 + src/telephony/actions.ts | 5 +++ src/telephony/preload.ts | 29 +++++++++++++ src/telephony/reducers.ts | 21 +++++++++ 11 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 src/telephony/actions.ts create mode 100644 src/telephony/preload.ts create mode 100644 src/telephony/reducers.ts diff --git a/electron-builder.json b/electron-builder.json index 10228961c6..0767d4a0c8 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -2,10 +2,10 @@ "files": ["app/**/*", "package.json"], "extraResources": ["build/icon.ico", "servers.json"], "appId": "chat.rocket", - "protocols": { - "name": "Rocket.Chat", - "schemes": ["rocketchat"] - }, + "protocols": [ + { "name": "Rocket.Chat", "schemes": ["rocketchat"] }, + { "name": "Rocket.Chat Telephony", "schemes": ["callto", "tel"] } + ], "afterPack": "./build/afterPack.js", "mac": { "category": "public.app-category.productivity", @@ -123,6 +123,7 @@ "executableName": "rocketchat-desktop", "category": "GNOME;GTK;Network;InstantMessaging", "desktop": { + "MimeType": "x-scheme-handler/rocketchat;x-scheme-handler/callto;x-scheme-handler/tel;", "entry": { "Name": "Rocket.Chat", "Comment": "Official Rocket.Chat Desktop Client", diff --git a/src/app/main/app.ts b/src/app/main/app.ts index c1101196dd..fce653bb9f 100644 --- a/src/app/main/app.ts +++ b/src/app/main/app.ts @@ -40,7 +40,11 @@ export const packageJsonInformation = { export const electronBuilderJsonInformation = { appId: electronBuilderJson.appId, - protocol: electronBuilderJson.protocols.schemes[0], + protocol: (electronBuilderJson.protocols as Array<{ schemes: string[] }>)[0] + .schemes[0], + protocols: ( + electronBuilderJson.protocols as Array<{ schemes: string[] }> + ).flatMap((p) => p.schemes), }; let isScreenCaptureFallbackForced = false; @@ -83,7 +87,9 @@ export const relaunchApp = (...args: string[]): void => { }; export const performElectronStartup = (): void => { - app.setAsDefaultProtocolClient(electronBuilderJsonInformation.protocol); + for (const scheme of electronBuilderJsonInformation.protocols) { + app.setAsDefaultProtocolClient(scheme); + } app.setAppUserModelId(electronBuilderJsonInformation.appId); app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); diff --git a/src/app/selectors.ts b/src/app/selectors.ts index cc10bfd3a3..6aa29b24cb 100644 --- a/src/app/selectors.ts +++ b/src/app/selectors.ts @@ -83,4 +83,6 @@ export const selectPersistableValues = createStructuredSelector({ }: RootState) => isDetailedEventsLoggingEnabled, isDebugLoggingEnabled: ({ isDebugLoggingEnabled }: RootState) => isDebugLoggingEnabled, + telephonyPreferredServer: ({ telephonyPreferredServer }: RootState) => + telephonyPreferredServer, }); diff --git a/src/deepLinks/main.ts b/src/deepLinks/main.ts index d8e298bc35..87cd4f17d5 100644 --- a/src/deepLinks/main.ts +++ b/src/deepLinks/main.ts @@ -1,5 +1,5 @@ import type { WebContents } from 'electron'; -import { app } from 'electron'; +import { app, dialog } from 'electron'; import { electronBuilderJsonInformation, @@ -8,6 +8,7 @@ import { import { ServerUrlResolutionStatus } from '../servers/common'; import { resolveServerUrl } from '../servers/main'; import { select, dispatch } from '../store'; +import { TELEPHONY_PREFERRED_SERVER_SET } from '../telephony/actions'; import { askForServerAddition, warnAboutInvalidServerUrl, @@ -54,6 +55,85 @@ const parseDeepLink = ( return null; }; +const TELEPHONY_PROTOCOLS = ['tel:', 'callto:']; + +export type TelephonyLink = { phoneNumber: string; rawUri: string }; + +export const parseTelephonyLink = (input: string): TelephonyLink | null => { + if (/^--/.test(input)) { + return null; + } + + let url: URL; + + try { + url = new URL(input); + } catch { + return null; + } + + if (!TELEPHONY_PROTOCOLS.includes(url.protocol)) { + return null; + } + + const raw = url.pathname || url.href.slice(url.protocol.length); + const phoneNumber = raw.replace(/^\/+/, '').replace(/[\s\-().]/g, ''); + + if (!phoneNumber) { + return null; + } + + return { phoneNumber, rawUri: input }; +}; + +export const performTelephonyCall = async ( + link: TelephonyLink +): Promise => { + const servers = select(({ servers }) => servers); + + if (servers.length === 0) { + return; + } + + let serverUrl: string; + + if (servers.length === 1) { + serverUrl = servers[0].url; + } else { + const preferredServer = select( + ({ telephonyPreferredServer }) => telephonyPreferredServer + ); + + if (preferredServer && servers.some((s) => s.url === preferredServer)) { + serverUrl = preferredServer; + } else { + const { response, checkboxChecked } = await dialog.showMessageBox({ + type: 'question', + title: 'Select Server', + message: 'Which server should handle this call?', + buttons: servers.map((s) => s.title ?? new URL(s.url).hostname), + checkboxLabel: 'Remember this choice', + checkboxChecked: false, + }); + + serverUrl = servers[response].url; + + if (checkboxChecked) { + dispatch({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: serverUrl, + }); + } + } + } + + const webContents = await getWebContents(serverUrl); + webContents.send('telephony/call-requested', { + phoneNumber: link.phoneNumber, + rawUri: link.rawUri, + }); +}; + export let processDeepLinksInArgs = async (): Promise => undefined; type AuthenticationParams = { @@ -180,6 +260,12 @@ const performConference = async ({ host, path }: InviteParams): Promise => }); const processDeepLink = async (deepLink: string): Promise => { + const telephonyLink = parseTelephonyLink(deepLink); + if (telephonyLink) { + await performTelephonyCall(telephonyLink); + return; + } + const parsedDeepLink = parseDeepLink(deepLink); if (!parsedDeepLink) { diff --git a/src/preload.ts b/src/preload.ts index f61560ad4e..f5c6e32b90 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -8,6 +8,7 @@ import { listenToScreenSharingRequests } from './screenSharing/preload'; import { RocketChatDesktop } from './servers/preload/api'; import { setServerUrl } from './servers/preload/urls'; import { createRendererReduxStore, listen } from './store'; +import { listenToTelephonyRequests } from './telephony/preload'; import { WEBVIEW_DID_NAVIGATE } from './ui/actions'; import { debounce } from './ui/main/debounce'; import { listenToMessageBoxEvents } from './ui/preload/messageBox'; @@ -64,6 +65,8 @@ const start = async (): Promise => { await invoke('server-view/ready'); + listenToTelephonyRequests(); + console.log('[Rocket.Chat Desktop] waiting for RocketChatDesktop.onReady'); RocketChatDesktop.onReady(() => { console.log('[Rocket.Chat Desktop] RocketChatDesktop.onReady fired'); diff --git a/src/servers/preload/api.ts b/src/servers/preload/api.ts index 9ebc67567d..b41b8d1a7e 100644 --- a/src/servers/preload/api.ts +++ b/src/servers/preload/api.ts @@ -14,6 +14,7 @@ import { clearOutlookCredentials, setUserToken, } from '../../outlookCalendar/preload'; +import { onTelephonyCallRequested } from '../../telephony/preload'; import { setUserPresenceDetection } from '../../userPresence/preload'; import { setBadge } from './badge'; import { writeTextToClipboard } from './clipboard'; @@ -49,6 +50,9 @@ type ExtendedIRocketChatDesktop = IRocketChatDesktop & { ) => Promise; closeCustomNotification: (id: unknown) => void; openInBrowser: (url: string) => void; + onTelephonyCallRequested: ( + callback: (payload: { phoneNumber: string; rawUri: string }) => void + ) => void; }; declare global { @@ -95,4 +99,5 @@ export const RocketChatDesktop: Window['RocketChatDesktop'] = { openDocumentViewer, openInBrowser, reloadServer, + onTelephonyCallRequested, }; diff --git a/src/store/actions.ts b/src/store/actions.ts index d305999258..bb409e50fa 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -9,6 +9,7 @@ import type { OutlookCalendarActionTypeToPayloadMap } from '../outlookCalendar/a import type { ScreenSharingActionTypeToPayloadMap } from '../screenSharing/actions'; import type { ServersActionTypeToPayloadMap } from '../servers/actions'; import type { SpellCheckingActionTypeToPayloadMap } from '../spellChecking/actions'; +import type { TelephonyActionTypeToPayloadMap } from '../telephony/actions'; import type { UiActionTypeToPayloadMap } from '../ui/actions'; import type { UpdatesActionTypeToPayloadMap } from '../updates/actions'; import type { UserPresenceActionTypeToPayloadMap } from '../userPresence/actions'; @@ -26,7 +27,8 @@ type ActionTypeToPayloadMap = AppActionTypeToPayloadMap & UiActionTypeToPayloadMap & UpdatesActionTypeToPayloadMap & UserPresenceActionTypeToPayloadMap & - OutlookCalendarActionTypeToPayloadMap; + OutlookCalendarActionTypeToPayloadMap & + TelephonyActionTypeToPayloadMap; type RootActions = { [Type in keyof ActionTypeToPayloadMap]: void extends ActionTypeToPayloadMap[Type] diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index a47f109ee6..f4d8194eeb 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -18,6 +18,7 @@ import { allowInsecureOutlookConnections } from '../outlookCalendar/reducers/all import { outlookCalendarSyncInterval } from '../outlookCalendar/reducers/outlookCalendarSyncInterval'; import { outlookCalendarSyncIntervalOverride } from '../outlookCalendar/reducers/outlookCalendarSyncIntervalOverride'; import { servers } from '../servers/reducers'; +import { telephonyPreferredServer } from '../telephony/reducers'; import { availableBrowsers } from '../ui/reducers/availableBrowsers'; import { currentView } from '../ui/reducers/currentView'; import { dialogs } from '../ui/reducers/dialogs'; @@ -118,6 +119,7 @@ export const rootReducer = combineReducers({ isVideoCallDevtoolsAutoOpenEnabled, isTransparentWindowEnabled, isVideoCallScreenCaptureFallbackEnabled, + telephonyPreferredServer, }); export type RootState = ReturnType; diff --git a/src/telephony/actions.ts b/src/telephony/actions.ts new file mode 100644 index 0000000000..739448af08 --- /dev/null +++ b/src/telephony/actions.ts @@ -0,0 +1,5 @@ +export const TELEPHONY_PREFERRED_SERVER_SET = 'telephony/preferred-server-set'; + +export type TelephonyActionTypeToPayloadMap = { + [TELEPHONY_PREFERRED_SERVER_SET]: string | null; +}; diff --git a/src/telephony/preload.ts b/src/telephony/preload.ts new file mode 100644 index 0000000000..6e3331fbf6 --- /dev/null +++ b/src/telephony/preload.ts @@ -0,0 +1,29 @@ +import { ipcRenderer } from 'electron'; + +type TelephonyPayload = { phoneNumber: string; rawUri: string }; + +let telephonyCallback: ((payload: TelephonyPayload) => void) | null = null; +let pendingPayload: TelephonyPayload | null = null; + +export const onTelephonyCallRequested = ( + callback: (payload: TelephonyPayload) => void +): void => { + telephonyCallback = callback; + if (pendingPayload) { + callback(pendingPayload); + pendingPayload = null; + } +}; + +export const listenToTelephonyRequests = (): void => { + ipcRenderer.on( + 'telephony/call-requested', + (_event, payload: TelephonyPayload) => { + if (telephonyCallback) { + telephonyCallback(payload); + } else { + pendingPayload = payload; + } + } + ); +}; diff --git a/src/telephony/reducers.ts b/src/telephony/reducers.ts new file mode 100644 index 0000000000..b17c793716 --- /dev/null +++ b/src/telephony/reducers.ts @@ -0,0 +1,21 @@ +import type { Reducer } from 'redux'; + +import type { ActionOf } from '../store/actions'; +import { TELEPHONY_PREFERRED_SERVER_SET } from './actions'; + +type TelephonyPreferredServerAction = ActionOf< + typeof TELEPHONY_PREFERRED_SERVER_SET +>; + +export const telephonyPreferredServer: Reducer< + string | null, + TelephonyPreferredServerAction +> = (state = null, action) => { + switch (action.type) { + case TELEPHONY_PREFERRED_SERVER_SET: + return action.payload; + + default: + return state; + } +}; From 154293f7225ad27db379f7870eb18eacb6241979 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Fri, 8 May 2026 18:43:36 -0300 Subject: [PATCH 02/55] test: add unit tests for telephony deep link parsing and routing Tests cover parseTelephonyLink (tel:/callto: protocols, number normalization, callto:// double-slash format, extension syntax, edge cases) and performTelephonyCall (0/1/2+ servers, preferred server persistence, dialog remember checkbox). --- src/deepLinks/main.spec.ts | 440 +++++++++++++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 src/deepLinks/main.spec.ts diff --git a/src/deepLinks/main.spec.ts b/src/deepLinks/main.spec.ts new file mode 100644 index 0000000000..043dd7de45 --- /dev/null +++ b/src/deepLinks/main.spec.ts @@ -0,0 +1,440 @@ +import { dialog } from 'electron'; + +import { ServerUrlResolutionStatus } from '../servers/common'; +import { resolveServerUrl } from '../servers/main'; +import { select, dispatch } from '../store'; +import { TELEPHONY_PREFERRED_SERVER_SET } from '../telephony/actions'; +import { telephonyPreferredServer } from '../telephony/reducers'; +import { getRootWindow } from '../ui/main/rootWindow'; +import { getWebContentsByServerUrl } from '../ui/main/serverView'; +import { + parseTelephonyLink, + performTelephonyCall, + setupDeepLinks, + processDeepLinksInArgs, +} from './main'; +import type { TelephonyLink } from './main'; + +jest.mock('electron', () => ({ + app: { + addListener: jest.fn(), + isPackaged: false, + getPath: jest.fn(), + getName: jest.fn(() => 'Rocket.Chat'), + }, + dialog: { showMessageBox: jest.fn() }, +})); +jest.mock('../store'); +jest.mock('../ui/main/serverView'); +jest.mock('../servers/main'); +jest.mock('../ui/main/dialogs'); +jest.mock('../ui/main/rootWindow'); +jest.mock('../app/main/app', () => ({ + electronBuilderJsonInformation: { protocol: 'rocketchat' }, + packageJsonInformation: { goUrlShortener: 'go.rocket.chat' }, +})); + +const selectMock = select as jest.MockedFunction; +const dispatchMock = dispatch as jest.MockedFunction; +const getWebContentsByServerUrlMock = + getWebContentsByServerUrl as jest.MockedFunction< + typeof getWebContentsByServerUrl + >; +const dialogMock = dialog as jest.Mocked; +const resolveServerUrlMock = resolveServerUrl as jest.MockedFunction< + typeof resolveServerUrl +>; +const getRootWindowMock = getRootWindow as jest.MockedFunction< + typeof getRootWindow +>; + +describe('deepLinks/main.ts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('parseTelephonyLink', () => { + it('should parse valid tel: with international format', () => { + const result = parseTelephonyLink('tel:+491234567890'); + expect(result).toEqual({ + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + }); + }); + + it('should parse valid callto: protocol', () => { + const result = parseTelephonyLink('callto:+1-800-555-0199'); + expect(result).toEqual({ + phoneNumber: '+18005550199', + rawUri: 'callto:+1-800-555-0199', + }); + }); + + it('should strip spaces, dashes, parens, and dots', () => { + const result = parseTelephonyLink('tel:(049) 123-456.78'); + expect(result).toEqual({ + phoneNumber: '04912345678', + rawUri: 'tel:(049) 123-456.78', + }); + }); + + it('should return null when empty number after stripping', () => { + const result = parseTelephonyLink('tel:---'); + expect(result).toBeNull(); + }); + + it('should return null for CLI flags starting with --', () => { + const result = parseTelephonyLink('--tel:+49123'); + expect(result).toBeNull(); + }); + + it('should return null for non-telephony protocols', () => { + const result = parseTelephonyLink('rocketchat://auth?host=x'); + expect(result).toBeNull(); + }); + + it('should return null for invalid URL', () => { + const result = parseTelephonyLink('not a url at all'); + expect(result).toBeNull(); + }); + + it('should return null for tel: with no number', () => { + const result = parseTelephonyLink('tel:'); + expect(result).toBeNull(); + }); + + it('should preserve + prefix', () => { + const result = parseTelephonyLink('tel:+44207123456'); + expect(result).toEqual({ + phoneNumber: '+44207123456', + rawUri: 'tel:+44207123456', + }); + }); + + it('should handle callto:// with double-slash (authority) format', () => { + const result = parseTelephonyLink('callto://+491234567890'); + expect(result).toEqual({ + phoneNumber: '+491234567890', + rawUri: 'callto://+491234567890', + }); + }); + + it('should preserve callto: with extension syntax', () => { + const result = parseTelephonyLink('callto:+1234;ext=5678'); + expect(result).toEqual({ + phoneNumber: '+1234;ext=5678', + rawUri: 'callto:+1234;ext=5678', + }); + }); + }); + + describe('performTelephonyCall', () => { + const mockWebContents = { + send: jest.fn(), + }; + + const mockLink: TelephonyLink = { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + }; + + beforeEach(() => { + // Mock getWebContentsByServerUrl to return immediately (no polling needed) + getWebContentsByServerUrlMock.mockReturnValue(mockWebContents as any); + }); + + it('should no-op when there are 0 servers', async () => { + selectMock.mockReturnValue([]); + + await performTelephonyCall(mockLink); + + expect(getWebContentsByServerUrlMock).not.toHaveBeenCalled(); + expect(dialogMock.showMessageBox).not.toHaveBeenCalled(); + }); + + it('should auto-select when there is 1 server', async () => { + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + await performTelephonyCall(mockLink); + + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://chat.example.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + } + ); + expect(dialogMock.showMessageBox).not.toHaveBeenCalled(); + }); + + it('should show dialog when there are 2+ servers and no preference', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce(null); + + dialogMock.showMessageBox.mockResolvedValue({ + response: 0, + checkboxChecked: false, + } as any); + + await performTelephonyCall(mockLink); + + expect(dialogMock.showMessageBox).toHaveBeenCalledWith({ + type: 'question', + title: 'Select Server', + message: 'Which server should handle this call?', + buttons: ['Server 1', 'Server 2'], + checkboxLabel: 'Remember this choice', + checkboxChecked: false, + }); + + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://server1.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + mockLink + ); + }); + + it('should skip dialog when preferred server exists in server list', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce('https://server2.com'); + + await performTelephonyCall(mockLink); + + expect(dialogMock.showMessageBox).not.toHaveBeenCalled(); + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://server2.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + mockLink + ); + }); + + it('should show dialog when preferred server is stale (not in server list)', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce('https://stale-server.com'); + + dialogMock.showMessageBox.mockResolvedValue({ + response: 1, + checkboxChecked: false, + } as any); + + await performTelephonyCall(mockLink); + + expect(dialogMock.showMessageBox).toHaveBeenCalled(); + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://server2.com' + ); + }); + + it('should dispatch TELEPHONY_PREFERRED_SERVER_SET when Remember is checked', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce(null); + + dialogMock.showMessageBox.mockResolvedValue({ + response: 0, + checkboxChecked: true, + } as any); + + await performTelephonyCall(mockLink); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: 'https://server1.com', + }); + }); + + it('should not dispatch when Remember is unchecked', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce(null); + + dialogMock.showMessageBox.mockResolvedValue({ + response: 1, + checkboxChecked: false, + } as any); + + await performTelephonyCall(mockLink); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it('should use hostname as button label when server title is missing', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com' }, + { url: 'https://server2.com' }, + ]) + .mockReturnValueOnce(null); + + dialogMock.showMessageBox.mockResolvedValue({ + response: 0, + checkboxChecked: false, + } as any); + + await performTelephonyCall(mockLink); + + expect(dialogMock.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: ['server1.com', 'server2.com'], + }) + ); + }); + + it('should poll for webContents when not immediately available', async () => { + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + let callCount = 0; + getWebContentsByServerUrlMock.mockImplementation(() => { + callCount++; + if (callCount < 3) { + return null as any; + } + return mockWebContents as any; + }); + + jest.useFakeTimers(); + const promise = performTelephonyCall(mockLink); + await jest.advanceTimersByTimeAsync(250); + await promise; + jest.useRealTimers(); + + expect(getWebContentsByServerUrlMock).toHaveBeenCalledTimes(3); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + mockLink + ); + }); + }); + + describe('processDeepLink telephony routing', () => { + const mockBrowserWindow = { + isVisible: jest.fn(() => true), + focus: jest.fn(), + showInactive: jest.fn(), + }; + + const mockWebContents = { + send: jest.fn(), + loadURL: jest.fn(), + }; + + beforeEach(() => { + getRootWindowMock.mockResolvedValue(mockBrowserWindow as any); + getWebContentsByServerUrlMock.mockReturnValue(mockWebContents as any); + }); + + it('should route tel: URL to telephony path', async () => { + setupDeepLinks(); + + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + const savedArgv = process.argv; + process.argv = ['electron', '.', 'tel:+491234567890']; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + // Telephony path: getWebContentsByServerUrl called for the server + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://chat.example.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + } + ); + // Normal deep link path NOT taken + expect(resolveServerUrlMock).not.toHaveBeenCalled(); + }); + + it('should route rocketchat:// URL to normal deep link path, not telephony', async () => { + setupDeepLinks(); + + resolveServerUrlMock.mockResolvedValue([ + 'https://chat.example.com', + ServerUrlResolutionStatus.OK, + undefined, + ] as any); + + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + const savedArgv = process.argv; + process.argv = [ + 'electron', + '.', + 'rocketchat://auth?host=https://chat.example.com&token=abc&userId=123', + ]; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + // Normal deep link path taken + expect(resolveServerUrlMock).toHaveBeenCalled(); + // Telephony dialog NOT shown (telephony branch skipped) + expect(dialogMock.showMessageBox).not.toHaveBeenCalled(); + }); + }); +}); + +describe('telephonyPreferredServer reducer', () => { + it('should return initial state as null', () => { + expect( + telephonyPreferredServer(undefined, { type: 'UNKNOWN_ACTION' } as any) + ).toBe(null); + }); + + it('should set preferred server URL', () => { + expect( + telephonyPreferredServer(null, { + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: 'https://chat.example.com', + }) + ).toBe('https://chat.example.com'); + }); + + it('should clear preferred server when payload is null', () => { + expect( + telephonyPreferredServer('https://chat.example.com', { + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: null, + }) + ).toBe(null); + }); +}); From ab816c0dbae0e94e5796b81a93ff119ff6d9a9ef Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Fri, 8 May 2026 19:15:04 -0300 Subject: [PATCH 03/55] fix: attach server selection dialog to root window and guard against duplicate IPC listener - Pass getRootWindow() as first argument to dialog.showMessageBox so the server selection prompt appears as a modal sheet attached to the main window (consistent with all other dialogs in the codebase) - Add idempotency guard to listenToTelephonyRequests to prevent duplicate IPC handler registration during hot-reload dev cycles --- src/deepLinks/main.spec.ts | 6 +++++- src/deepLinks/main.ts | 19 +++++++++++-------- src/telephony/preload.ts | 7 +++++++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/deepLinks/main.spec.ts b/src/deepLinks/main.spec.ts index 043dd7de45..4d349d8da1 100644 --- a/src/deepLinks/main.spec.ts +++ b/src/deepLinks/main.spec.ts @@ -49,8 +49,11 @@ const getRootWindowMock = getRootWindow as jest.MockedFunction< >; describe('deepLinks/main.ts', () => { + const mockRootWindow = {} as any; + beforeEach(() => { jest.clearAllMocks(); + getRootWindowMock.mockResolvedValue(mockRootWindow); }); describe('parseTelephonyLink', () => { @@ -187,7 +190,7 @@ describe('deepLinks/main.ts', () => { await performTelephonyCall(mockLink); - expect(dialogMock.showMessageBox).toHaveBeenCalledWith({ + expect(dialogMock.showMessageBox).toHaveBeenCalledWith(mockRootWindow, { type: 'question', title: 'Select Server', message: 'Which server should handle this call?', @@ -301,6 +304,7 @@ describe('deepLinks/main.ts', () => { await performTelephonyCall(mockLink); expect(dialogMock.showMessageBox).toHaveBeenCalledWith( + expect.anything(), expect.objectContaining({ buttons: ['server1.com', 'server2.com'], }) diff --git a/src/deepLinks/main.ts b/src/deepLinks/main.ts index 87cd4f17d5..478cd575cf 100644 --- a/src/deepLinks/main.ts +++ b/src/deepLinks/main.ts @@ -107,14 +107,17 @@ export const performTelephonyCall = async ( if (preferredServer && servers.some((s) => s.url === preferredServer)) { serverUrl = preferredServer; } else { - const { response, checkboxChecked } = await dialog.showMessageBox({ - type: 'question', - title: 'Select Server', - message: 'Which server should handle this call?', - buttons: servers.map((s) => s.title ?? new URL(s.url).hostname), - checkboxLabel: 'Remember this choice', - checkboxChecked: false, - }); + const { response, checkboxChecked } = await dialog.showMessageBox( + await getRootWindow(), + { + type: 'question', + title: 'Select Server', + message: 'Which server should handle this call?', + buttons: servers.map((s) => s.title ?? new URL(s.url).hostname), + checkboxLabel: 'Remember this choice', + checkboxChecked: false, + } + ); serverUrl = servers[response].url; diff --git a/src/telephony/preload.ts b/src/telephony/preload.ts index 6e3331fbf6..35de60ee26 100644 --- a/src/telephony/preload.ts +++ b/src/telephony/preload.ts @@ -15,7 +15,14 @@ export const onTelephonyCallRequested = ( } }; +let listening = false; + export const listenToTelephonyRequests = (): void => { + if (listening) { + return; + } + listening = true; + ipcRenderer.on( 'telephony/call-requested', (_event, payload: TelephonyPayload) => { From 8431a5f138bbdc416bc38508db5df7a45597c409 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Fri, 8 May 2026 23:15:31 -0300 Subject: [PATCH 04/55] chore: add GitNexus code intelligence config to CLAUDE.md Add GitNexus section with impact analysis, query, and context tools. Gitignore .gitnexus index directory. --- .gitignore | 1 + CLAUDE.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/.gitignore b/.gitignore index 799ab6be0f..dc885ae4c7 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ scripts/msi-test/.known_hosts # legacy agent docs (consolidated into CLAUDE.md) AGENTS.md src/outlookCalendar/AGENTS.md +.gitnexus diff --git a/CLAUDE.md b/CLAUDE.md index 9ebb4325dc..e5681946f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,3 +103,47 @@ git worktree add ../Rocket.Chat.Electron-worktrees/feature-name -b new-branch ma - Use measurable descriptions: "reduced memory usage", "improved by X%" - **Never invent metrics** — no estimated time spent, no speculated user counts. Only include numbers from actual logs, error messages, or documented sources. - PR descriptions: straightforward language, focus on what changed and why + + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **Rocket.Chat.Electron** (4593 symbols, 7029 relationships, 103 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/Rocket.Chat.Electron/context` | Codebase overview, check index freshness | +| `gitnexus://repo/Rocket.Chat.Electron/clusters` | All functional areas | +| `gitnexus://repo/Rocket.Chat.Electron/processes` | All execution flows | +| `gitnexus://repo/Rocket.Chat.Electron/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + From 5050a1d9f4775f8c30250d23f6c5ffaed0330e39 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Fri, 8 May 2026 23:15:37 -0300 Subject: [PATCH 05/55] feat: support sha-prefixed exception versions by git commit hash Dispatch WEBVIEW_GIT_COMMIT_HASH_CHANGED from server info response. Match supportedVersions exceptions using sha: prefix against the server's git commit hash for per-build version overrides. --- .../supportedVersions/main.main.spec.ts | 101 +++++++++++++++++- src/servers/supportedVersions/main.ts | 47 +++++++- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/src/servers/supportedVersions/main.main.spec.ts b/src/servers/supportedVersions/main.main.spec.ts index 8e74b3eae6..f7475b833e 100644 --- a/src/servers/supportedVersions/main.main.spec.ts +++ b/src/servers/supportedVersions/main.main.spec.ts @@ -11,6 +11,7 @@ import { WEBVIEW_READY, WEBVIEW_SERVER_RELOADED, SUPPORTED_VERSION_DIALOG_DISMISS, + WEBVIEW_GIT_COMMIT_HASH_CHANGED, } from '../../ui/actions'; import { checkSupportedVersionServers, @@ -175,6 +176,32 @@ describe('supportedVersions/main.ts', () => { }); }); + it('should dispatch git commit hash from server info', async () => { + const mockServer = createMockServer(); + const mockServerInfo = createMockServerInfo({ + commit: { + ...createMockServerInfo().commit, + hash: 'bb83777b51a42d', + }, + }); + selectMock.mockReturnValue(mockServer); + axiosMock.get = jest.fn().mockResolvedValue({ data: mockServerInfo }); + + (jest.spyOn(jsonwebtoken, 'verify') as jest.Mock).mockReturnValue( + createMockSupportedVersions() + ); + + await updateSupportedVersionsData(mockServer.url); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: WEBVIEW_GIT_COMMIT_HASH_CHANGED, + payload: { + url: mockServer.url, + gitCommitHash: 'bb83777b51a42d', + }, + }); + }); + it('should retry server fetch on failure and succeed on retry', async () => { const mockServer = createMockServer(); const mockServerInfo = createMockServerInfo(); @@ -766,7 +793,7 @@ describe('supportedVersions/main.ts', () => { }); it('should optionally include message property', async () => { - const futureDate = new Date(Date.now() + 86400000).toISOString(); + const futureDate = new Date(Date.now() + 86400000); const supportedVersions = { versions: [ { @@ -807,6 +834,78 @@ describe('supportedVersions/main.ts', () => { expect(result.supported).toBe(true); }); + + it('should support sha-prefixed exception versions by git commit hash', async () => { + const futureDate = new Date(Date.now() + 86400000); + const supportedVersions: SupportedVersions = { + enforcementStartDate: new Date(Date.now() - 86400000).toISOString(), + timestamp: new Date().toISOString(), + versions: [ + { + version: '8.4.0', + expiration: futureDate, + }, + ], + exceptions: { + domain: 'open.rocket.chat', + uniqueId: 'test-unique-id', + versions: [ + { + version: 'sha-bb83777', + expiration: futureDate, + }, + ], + }, + }; + + const result = await isServerVersionSupported( + { + url: 'https://open.rocket.chat/', + version: '8.5', + title: 'Rocket.Chat Open', + gitCommitHash: 'bb83777b51a42d', + } as any, + supportedVersions + ); + + expect(result.supported).toBe(true); + }); + + it('should not match malformed exception versions by git commit hash', async () => { + const futureDate = new Date(Date.now() + 86400000); + const supportedVersions: SupportedVersions = { + enforcementStartDate: new Date(Date.now() - 86400000).toISOString(), + timestamp: new Date().toISOString(), + versions: [ + { + version: '8.4.0', + expiration: futureDate, + }, + ], + exceptions: { + domain: 'open.rocket.chat', + uniqueId: 'test-unique-id', + versions: [ + { + version: '', + expiration: futureDate, + }, + ], + }, + }; + + const result = await isServerVersionSupported( + { + url: 'https://open.rocket.chat/', + version: '8.5', + title: 'Rocket.Chat Open', + gitCommitHash: 'bb83777b51a42d', + } as any, + supportedVersions + ); + + expect(result.supported).toBe(false); + }); }); describe('Cache and Retry Integration', () => { diff --git a/src/servers/supportedVersions/main.ts b/src/servers/supportedVersions/main.ts index 9fdae6c7b8..0f22b22854 100644 --- a/src/servers/supportedVersions/main.ts +++ b/src/servers/supportedVersions/main.ts @@ -19,6 +19,7 @@ import { WEBVIEW_SERVER_UNIQUE_ID_UPDATED, WEBVIEW_SERVER_RELOADED, SUPPORTED_VERSION_DIALOG_DISMISS, + WEBVIEW_GIT_COMMIT_HASH_CHANGED, } from '../../ui/actions'; import * as urls from '../../urls'; import type { Server } from '../common'; @@ -207,6 +208,40 @@ const getExpirationMessage = ({ return message; }; +const isVersionExceptionForServer = ( + exceptionVersion: string, + server: Server, + serverVersionTilde: string +): boolean => { + if (satisfies(coerce(exceptionVersion)?.version ?? '', serverVersionTilde)) { + return true; + } + + const trimmedExceptionVersion = exceptionVersion.trim(); + if (!trimmedExceptionVersion.startsWith('sha-')) { + return false; + } + + const normalizedExceptionVersion = trimmedExceptionVersion + .replace(/^sha-/, '') + .toLowerCase(); + if (!normalizedExceptionVersion) { + return false; + } + + const gitCommitHash = server.gitCommitHash?.trim(); + if (!gitCommitHash) { + return false; + } + + const normalizedGitCommitHash = gitCommitHash + .trim() + .replace(/^sha-/, '') + .toLowerCase(); + + return normalizedGitCommitHash.startsWith(normalizedExceptionVersion); +}; + export const getExpirationMessageTranslated = ( i18n: Dictionary | undefined, message: Message, @@ -274,7 +309,7 @@ export const isServerVersionSupported = async ( if (!supportedVersionsData) return { supported: true }; const exception = exceptions?.versions?.find(({ version }) => - satisfies(coerce(version)?.version ?? '', serverVersionTilde) + isVersionExceptionForServer(version, server, serverVersionTilde) ); if (exception) { @@ -352,6 +387,16 @@ const dispatchVersionUpdated = (url: string) => (info: ServerInfo) => { }, }); + if (info.commit?.hash) { + dispatch({ + type: WEBVIEW_GIT_COMMIT_HASH_CHANGED, + payload: { + url, + gitCommitHash: info.commit.hash, + }, + }); + } + return info; }; From c83728f10764aa8b58bed8ae7b706850129cd72a Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Fri, 8 May 2026 23:15:52 -0300 Subject: [PATCH 06/55] feat: add telephony preferred server settings UI Add TelephonyServer component to Settings > General tab with a Select dropdown to choose which server handles tel:/callto: links. Hidden when only one server exists. "Auto (ask each time)" option clears the preference and reverts to dialog behavior. --- src/i18n/en.i18n.json | 5 ++ src/ui/components/SettingsView/GeneralTab.tsx | 2 + .../SettingsView/features/TelephonyServer.tsx | 77 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 src/ui/components/SettingsView/features/TelephonyServer.tsx diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index d4d3632a3f..3ec2400305 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -292,6 +292,11 @@ "loading": "Loading browsers...", "current": "Currently using:" }, + "telephonyServer": { + "title": "Telephony Server", + "description": "Choose which server handles incoming phone calls (tel: and callto: links).", + "auto": "Auto (ask each time)" + }, "clearPermittedScreenCaptureServers": { "title": "Clear Screen Capture Permissions", "description": "Clear the screen capture permissions that was selected to not ask again on video calls." diff --git a/src/ui/components/SettingsView/GeneralTab.tsx b/src/ui/components/SettingsView/GeneralTab.tsx index bab5933158..3c1d6b9199 100644 --- a/src/ui/components/SettingsView/GeneralTab.tsx +++ b/src/ui/components/SettingsView/GeneralTab.tsx @@ -12,6 +12,7 @@ import { OutlookCalendarSyncInterval } from './features/OutlookCalendarSyncInter import { ReportErrors } from './features/ReportErrors'; import { ScreenCaptureFallback } from './features/ScreenCaptureFallback'; import { SideBar } from './features/SideBar'; +import { TelephonyServer } from './features/TelephonyServer'; import { ThemeAppearance } from './features/ThemeAppearance'; import { TransparentWindow } from './features/TransparentWindow'; import { TrayIcon } from './features/TrayIcon'; @@ -35,6 +36,7 @@ export const GeneralTab = () => ( + {!process.mas && } diff --git a/src/ui/components/SettingsView/features/TelephonyServer.tsx b/src/ui/components/SettingsView/features/TelephonyServer.tsx new file mode 100644 index 0000000000..0341537833 --- /dev/null +++ b/src/ui/components/SettingsView/features/TelephonyServer.tsx @@ -0,0 +1,77 @@ +import { + Box, + Field, + FieldLabel, + FieldHint, + Select, +} from '@rocket.chat/fuselage'; +import { useCallback, useMemo } from 'react'; +import type { Key } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import type { Dispatch } from 'redux'; + +import type { RootAction } from '../../../../store/actions'; +import type { RootState } from '../../../../store/rootReducer'; +import { TELEPHONY_PREFERRED_SERVER_SET } from '../../../../telephony/actions'; + +export const TelephonyServer = () => { + const servers = useSelector(({ servers }: RootState) => servers); + const telephonyPreferredServer = useSelector( + ({ telephonyPreferredServer }: RootState) => telephonyPreferredServer + ); + const dispatch = useDispatch>(); + const { t } = useTranslation(); + + const handleChange = useCallback( + (value: Key) => { + const stringValue = String(value); + dispatch({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: stringValue === 'auto' ? null : stringValue, + }); + }, + [dispatch] + ); + + const options = useMemo( + (): [string, string][] => [ + ['auto', t('settings.options.telephonyServer.auto')], + ...servers.map((s): [string, string] => [ + s.url, + s.title ?? new URL(s.url).hostname, + ]), + ], + [servers, t] + ); + + if (servers.length <= 1) { + return null; + } + + return ( + + + + {t('settings.options.telephonyServer.title')} + + {t('settings.options.telephonyServer.description')} + + + + +// so tests can drive onChange without user-event or ARIA interaction complexity. +jest.mock('@rocket.chat/fuselage', () => { + const actual = jest.requireActual('@rocket.chat/fuselage'); + return { + ...actual, + Select: ({ + options, + value, + onChange, + }: { + options: [string, string][]; + value: string; + onChange: (key: Key) => void; + }) => ( + + ), + }; +}); + +type PartialState = Pick; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +describe('TelephonyServer', () => { + it('renders nothing when servers is empty', () => { + const store = makeStore({ servers: [], telephonyPreferredServer: null }); + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when servers.length === 1', () => { + const store = makeStore({ + servers: [{ url: 'https://chat.example.com', title: 'Example' }], + telephonyPreferredServer: null, + }); + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders Select with N+1 options when servers.length >= 2', () => { + const store = makeStore({ + servers: [ + { url: 'https://chat.alpha.com', title: 'Alpha' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: null, + }); + render( + + + + ); + const select = screen.getByTestId('telephony-select'); + const options = select.querySelectorAll('option'); + // auto + 2 servers = 3 + expect(options).toHaveLength(3); + expect(options[0]).toHaveValue('auto'); + expect(options[1]).toHaveValue('https://chat.alpha.com'); + expect(options[2]).toHaveValue('https://chat.beta.com'); + }); + + it('shows telephonyPreferredServer as current Select value', () => { + const store = makeStore({ + servers: [ + { url: 'https://chat.alpha.com', title: 'Alpha' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: 'https://chat.beta.com', + }); + render( + + + + ); + const select = screen.getByTestId('telephony-select'); + expect(select.value).toBe('https://chat.beta.com'); + }); + + it('shows "auto" as Select value when telephonyPreferredServer is null', () => { + const store = makeStore({ + servers: [ + { url: 'https://chat.alpha.com', title: 'Alpha' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: null, + }); + render( + + + + ); + const select = screen.getByTestId('telephony-select'); + expect(select.value).toBe('auto'); + }); + + it('onChange to a server URL dispatches TELEPHONY_PREFERRED_SERVER_SET with the URL', () => { + const store = makeStore({ + servers: [ + { url: 'https://chat.alpha.com', title: 'Alpha' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: null, + }); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + const select = screen.getByTestId('telephony-select'); + fireEvent.change(select, { target: { value: 'https://chat.alpha.com' } }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: 'https://chat.alpha.com', + }); + }); + + it('onChange to "auto" dispatches TELEPHONY_PREFERRED_SERVER_SET with null payload', () => { + const store = makeStore({ + servers: [ + { url: 'https://chat.alpha.com', title: 'Alpha' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: 'https://chat.alpha.com', + }); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + const select = screen.getByTestId('telephony-select'); + fireEvent.change(select, { target: { value: 'auto' } }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: null, + }); + }); + + it('falls back to hostname when server has no title', () => { + const store = makeStore({ + servers: [ + { url: 'https://example.rocketchat.com' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: null, + }); + render( + + + + ); + const option = screen.getByRole('option', { + name: 'example.rocketchat.com', + }); + expect(option).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/TelephonyServerSelectModal/index.spec.tsx b/src/ui/components/TelephonyServerSelectModal/index.spec.tsx new file mode 100644 index 0000000000..5cc50bc44b --- /dev/null +++ b/src/ui/components/TelephonyServerSelectModal/index.spec.tsx @@ -0,0 +1,210 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import type { RootState } from '../../../store/rootReducer'; +import { TELEPHONY_SERVER_SELECT_CLOSE } from '../../actions'; +import { TelephonyServerSelectModal } from './index'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Dialog uses .showModal() which is not available in all test environments. +// Mock to a simple conditional wrapper so tests stay focused on modal content/dispatch logic. +jest.mock('../Dialog', () => ({ + Dialog: ({ + children, + isVisible, + onClose, + }: { + children?: ReactNode; + isVisible?: boolean; + onClose?: () => void; + }) => + isVisible ? ( +
+ + {children} +
+ ) : null, +})); + +type PartialState = Pick; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +const twoServers = [ + { url: 'https://chat.alpha.com', title: 'Alpha Chat' }, + { url: 'https://chat.beta.com', title: 'Beta Chat' }, +]; + +const openDialogState = ( + servers: PartialState['servers'] = twoServers +): PartialState => ({ + servers, + dialogs: { + telephonyServerSelect: { + isOpen: true, + phoneNumber: '123', + rawUri: 'tel:123', + }, + serverInfoModal: { isOpen: false, serverData: null }, + } as unknown as RootState['dialogs'], +}); + +const closedDialogState = ( + servers: PartialState['servers'] = twoServers +): PartialState => ({ + servers, + dialogs: { + telephonyServerSelect: { + isOpen: false, + phoneNumber: '', + rawUri: '', + }, + serverInfoModal: { isOpen: false, serverData: null }, + } as unknown as RootState['dialogs'], +}); + +describe('TelephonyServerSelectModal', () => { + it('renders nothing when dialog is closed', () => { + const store = makeStore(closedDialogState()); + const { container } = render( + + + + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(container.firstChild).toBeNull(); + }); + + it('renders server items when dialog is open with 2 servers', () => { + const store = makeStore(openDialogState()); + render( + + + + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Alpha Chat')).toBeInTheDocument(); + expect(screen.getByText('Beta Chat')).toBeInTheDocument(); + }); + + it('clicking a server dispatches TELEPHONY_SERVER_SELECT_CLOSE with serverUrl and rememberChoice false', () => { + const store = makeStore(openDialogState()); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + fireEvent.click(screen.getByText('Alpha Chat')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: { + serverUrl: 'https://chat.alpha.com', + rememberChoice: false, + }, + }); + }); + + it('clicking a server with rememberChoice checked dispatches rememberChoice true', () => { + const store = makeStore(openDialogState()); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + // Toggle the checkbox via the label (label uses onClick to toggle state) + const label = screen.getByText( + 'dialog.telephonySelectServer.rememberChoice' + ); + fireEvent.click(label); + + fireEvent.click(screen.getByText('Alpha Chat')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: { + serverUrl: 'https://chat.alpha.com', + rememberChoice: true, + }, + }); + }); + + it('dialog onClose dispatches TELEPHONY_SERVER_SELECT_CLOSE with null payload', () => { + const store = makeStore(openDialogState()); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('dialog-close')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: null, + }); + }); + + it('rememberChoice resets to false after close', () => { + const store = makeStore(openDialogState()); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + const { rerender } = render( + + + + ); + + // Toggle rememberChoice on + const label = screen.getByText( + 'dialog.telephonySelectServer.rememberChoice' + ); + fireEvent.click(label); + + // Close the dialog + fireEvent.click(screen.getByTestId('dialog-close')); + + // Reopen: rebuild store with open state (simulates a new open event) + const store2 = makeStore(openDialogState()); + const dispatchSpy2 = jest.spyOn(store2, 'dispatch'); + + rerender( + + + + ); + + // After reopening, click a server — rememberChoice should be false (was reset by close) + fireEvent.click(screen.getByText('Alpha Chat')); + + expect(dispatchSpy2).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: { + serverUrl: 'https://chat.alpha.com', + rememberChoice: false, + }, + }); + + // Suppress unused var warning — dispatchSpy was used to trigger close above + expect(dispatchSpy).toHaveBeenCalled(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 2308e5d18b..a99b55df27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -63,6 +63,13 @@ __metadata: languageName: node linkType: hard +"@adobe/css-tools@npm:^4.4.0": + version: 4.4.4 + resolution: "@adobe/css-tools@npm:4.4.4" + checksum: 10/0abd4715737877e5aa5d730d6ec2cffae2131102ddc8310ac5ba3f457ffb2ef453324dbb5b927e3cbc3f81bdd29ce485754014c6e64f4577a49540c76e26ac6b + languageName: node + linkType: hard + "@ampproject/remapping@npm:^2.2.0": version: 2.3.0 resolution: "@ampproject/remapping@npm:2.3.0" @@ -73,7 +80,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.21.4, @babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.21.4, @babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" dependencies: @@ -5116,6 +5123,56 @@ __metadata: languageName: node linkType: hard +"@testing-library/dom@npm:~10.4.1": + version: 10.4.1 + resolution: "@testing-library/dom@npm:10.4.1" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.3.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + picocolors: "npm:1.1.1" + pretty-format: "npm:^27.0.2" + checksum: 10/7f93e09ea015f151f8b8f42cbab0b2b858999b5445f15239a72a612ef7716e672b14c40c421218194cf191cbecbde0afa6f3dc2cc83dda93ff6a4fb0237df6e6 + languageName: node + linkType: hard + +"@testing-library/jest-dom@npm:~6.9.1": + version: 6.9.1 + resolution: "@testing-library/jest-dom@npm:6.9.1" + dependencies: + "@adobe/css-tools": "npm:^4.4.0" + aria-query: "npm:^5.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + picocolors: "npm:^1.1.1" + redent: "npm:^3.0.0" + checksum: 10/409b4f519e4c68f4d31e3b0317338cc19098b9029513fca61aa2af8270086ae3956a1eaedd19bbce2d2c9e2cf9ff27a616c06556be7a26e101c0d529a0062233 + languageName: node + linkType: hard + +"@testing-library/react@npm:~16.3.2": + version: 16.3.2 + resolution: "@testing-library/react@npm:16.3.2" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/0ca88c6f672d00c2afd1bdedeff9b5382dd8157038efeb9762dc016731030075624be7106b92d2b5e5c52812faea85263e69272c14b6f8700eb48a4a8af6feef + languageName: node + linkType: hard + "@tokenizer/token@npm:^0.3.0": version: 0.3.0 resolution: "@tokenizer/token@npm:0.3.0" @@ -5174,6 +5231,13 @@ __metadata: languageName: node linkType: hard +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: 10/c0084c389dc030daeaf0115a92ce43a3f4d42fc8fef2d0e22112d87a42798d4a15aac413019d4a63f868327d52ad6740ab99609462b442fe6b9286b172d2e82e + languageName: node + linkType: hard + "@types/babel__core@npm:^7.1.14": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" @@ -6329,7 +6393,16 @@ __metadata: languageName: node linkType: hard -"aria-query@npm:^5.3.2": +"aria-query@npm:5.3.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" + dependencies: + dequal: "npm:^2.0.3" + checksum: 10/c3e1ed127cc6886fea4732e97dd6d3c3938e64180803acfb9df8955517c4943760746ffaf4020ce8f7ffaa7556a3b5f85c3769a1f5ca74a1288e02d042f9ae4e + languageName: node + linkType: hard + +"aria-query@npm:^5.0.0, aria-query@npm:^5.3.2": version: 5.3.2 resolution: "aria-query@npm:5.3.2" checksum: 10/b2fe9bc98bd401bc322ccb99717c1ae2aaf53ea0d468d6e7aebdc02fac736e4a99b46971ee05b783b08ade23c675b2d8b60e4a1222a95f6e27bc4d2a0bfdcc03 @@ -7942,6 +8015,13 @@ __metadata: languageName: node linkType: hard +"css.escape@npm:^1.5.1": + version: 1.5.1 + resolution: "css.escape@npm:1.5.1" + checksum: 10/f6d38088d870a961794a2580b2b2af1027731bb43261cfdce14f19238a88664b351cc8978abc20f06cc6bbde725699dec8deb6fe9816b139fc3f2af28719e774 + languageName: node + linkType: hard + "cssom@npm:^0.5.0": version: 0.5.0 resolution: "cssom@npm:0.5.0" @@ -8224,6 +8304,13 @@ __metadata: languageName: node linkType: hard +"dequal@npm:^2.0.3": + version: 2.0.3 + resolution: "dequal@npm:2.0.3" + checksum: 10/6ff05a7561f33603df87c45e389c9ac0a95e3c056be3da1a0c4702149e3a7f6fe5ffbb294478687ba51a9e95f3a60e8b6b9005993acd79c292c7d15f71964b6b + languageName: node + linkType: hard + "detect-browsers@npm:~6.1.0": version: 6.1.0 resolution: "detect-browsers@npm:6.1.0" @@ -8364,6 +8451,20 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 10/377b4a7f9eae0a5d72e1068c369c99e0e4ca17fdfd5219f3abd32a73a590749a267475a59d7b03a891f9b673c27429133a818c44b2e47e32fec024b34274e2ca + languageName: node + linkType: hard + +"dom-accessibility-api@npm:^0.6.3": + version: 0.6.3 + resolution: "dom-accessibility-api@npm:0.6.3" + checksum: 10/83d3371f8226487fbad36e160d44f1d9017fb26d46faba6a06fcad15f34633fc827b8c3e99d49f71d5f3253d866e2131826866fd0a3c86626f8eccfc361881ff + languageName: node + linkType: hard + "dom-serializer@npm:^2.0.0": version: 2.0.0 resolution: "dom-serializer@npm:2.0.0" @@ -12498,6 +12599,15 @@ __metadata: languageName: node linkType: hard +"lz-string@npm:^1.5.0": + version: 1.5.0 + resolution: "lz-string@npm:1.5.0" + bin: + lz-string: bin/bin.js + checksum: 10/e86f0280e99a8d8cd4eef24d8601ddae15ce54e43ac9990dfcb79e1e081c255ad24424a30d78d2ad8e51a8ce82a66a930047fed4b4aa38c6f0b392ff9300edfc + languageName: node + linkType: hard + "magic-string@npm:^0.30.3": version: 0.30.21 resolution: "magic-string@npm:0.30.21" @@ -13894,7 +14004,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.1.1": +"picocolors@npm:1.1.1, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10/e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045 @@ -14062,6 +14172,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 10/248990cbef9e96fb36a3e1ae6b903c551ca4ddd733f8d0912b9cc5141d3d0b3f9f8dfb4d799fb1c6723382c9c2083ffbfa4ad43ff9a0e7535d32d41fd5f01da6 + languageName: node + linkType: hard + "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -14401,6 +14522,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10/73b36281e58eeb27c9cc6031301b6ae19ecdc9f18ae2d518bdb39b0ac564e65c5779405d623f1df9abf378a13858b79442480244bd579968afc1faf9a2ce5e05 + languageName: node + linkType: hard + "react-is@npm:^18.0.0, react-is@npm:^18.2.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -15018,6 +15146,9 @@ __metadata: "@rollup/plugin-json": "npm:~6.1.0" "@rollup/plugin-node-resolve": "npm:~15.2.3" "@rollup/plugin-replace": "npm:~5.0.5" + "@testing-library/dom": "npm:~10.4.1" + "@testing-library/jest-dom": "npm:~6.9.1" + "@testing-library/react": "npm:~16.3.2" "@types/archiver": "npm:~7.0.0" "@types/dompurify": "npm:~3.2.0" "@types/electron-devtools-installer": "npm:~2.2.5" From 1e04a7b3c4e4e64c05357c561ae970273a0d4218 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 13 May 2026 17:22:51 -0300 Subject: [PATCH 12/55] fix(telephony): decode percent-encoded URI before sanitization tel:%2B15551234 left %2B encoded, producing phoneNumber '%2B15551234' instead of '+15551234'. decodeURIComponent runs before strip pass; malformed escapes return null (treated same as other invalid input). --- src/deepLinks/main.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/deepLinks/main.ts b/src/deepLinks/main.ts index 8f1d6d582d..08cefc8194 100644 --- a/src/deepLinks/main.ts +++ b/src/deepLinks/main.ts @@ -80,7 +80,15 @@ export const parseTelephonyLink = (input: string): TelephonyLink | null => { return null; } - const raw = url.pathname || url.href.slice(url.protocol.length); + let raw: string; + try { + raw = decodeURIComponent( + url.pathname || url.href.slice(url.protocol.length) + ); + } catch { + return null; + } + const phoneNumber = raw.replace(/^\/+/, '').replace(/[\s\-().]/g, ''); if (!phoneNumber) { From 8f1b2f4738ccdf56d2c802666bf731e560a5843f Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 13 May 2026 17:22:56 -0300 Subject: [PATCH 13/55] fix(supportedVersions): make sha- exception prefix check case-insensitive Git commit hashes are conventionally case-insensitive. SHA-bb83777 should match same as sha-bb83777. --- src/servers/supportedVersions/main.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/servers/supportedVersions/main.ts b/src/servers/supportedVersions/main.ts index 0f22b22854..6ce59564be 100644 --- a/src/servers/supportedVersions/main.ts +++ b/src/servers/supportedVersions/main.ts @@ -218,12 +218,12 @@ const isVersionExceptionForServer = ( } const trimmedExceptionVersion = exceptionVersion.trim(); - if (!trimmedExceptionVersion.startsWith('sha-')) { + if (!trimmedExceptionVersion.toLowerCase().startsWith('sha-')) { return false; } const normalizedExceptionVersion = trimmedExceptionVersion - .replace(/^sha-/, '') + .replace(/^sha-/i, '') .toLowerCase(); if (!normalizedExceptionVersion) { return false; @@ -236,7 +236,7 @@ const isVersionExceptionForServer = ( const normalizedGitCommitHash = gitCommitHash .trim() - .replace(/^sha-/, '') + .replace(/^sha-/i, '') .toLowerCase(); return normalizedGitCommitHash.startsWith(normalizedExceptionVersion); From 3c2ab23d0e903f8b5789cb5a4f823271f00bca6f Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 13 May 2026 17:23:01 -0300 Subject: [PATCH 14/55] fix(telephony-ui): harden URL parsing and improve modal accessibility - TelephonyServer: extract hostname via safeHostname helper to prevent settings page crash on malformed server URLs (new URL() throws). - TelephonyServerSelectModal: associate 'Remember this choice' label with checkbox via htmlFor/id for assistive tech. - ServerItem: render Tile as native button (is='button' type='button') so keyboard users get Tab focus and Enter/Space activation. --- .../SettingsView/features/TelephonyServer.tsx | 10 +++++++++- .../TelephonyServerSelectModal/ServerItem.tsx | 2 ++ src/ui/components/TelephonyServerSelectModal/index.tsx | 3 ++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/ui/components/SettingsView/features/TelephonyServer.tsx b/src/ui/components/SettingsView/features/TelephonyServer.tsx index 0341537833..2cd1585c89 100644 --- a/src/ui/components/SettingsView/features/TelephonyServer.tsx +++ b/src/ui/components/SettingsView/features/TelephonyServer.tsx @@ -15,6 +15,14 @@ import type { RootAction } from '../../../../store/actions'; import type { RootState } from '../../../../store/rootReducer'; import { TELEPHONY_PREFERRED_SERVER_SET } from '../../../../telephony/actions'; +const safeHostname = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return url; + } +}; + export const TelephonyServer = () => { const servers = useSelector(({ servers }: RootState) => servers); const telephonyPreferredServer = useSelector( @@ -39,7 +47,7 @@ export const TelephonyServer = () => { ['auto', t('settings.options.telephonyServer.auto')], ...servers.map((s): [string, string] => [ s.url, - s.title ?? new URL(s.url).hostname, + s.title ?? safeHostname(s.url), ]), ], [servers, t] diff --git a/src/ui/components/TelephonyServerSelectModal/ServerItem.tsx b/src/ui/components/TelephonyServerSelectModal/ServerItem.tsx index b326ea9aec..0af69c1b8c 100644 --- a/src/ui/components/TelephonyServerSelectModal/ServerItem.tsx +++ b/src/ui/components/TelephonyServerSelectModal/ServerItem.tsx @@ -37,6 +37,8 @@ export const ServerItem = ({ return ( { setRememberChoice(!rememberChoice)} /> setRememberChoice(!rememberChoice)} > {t('dialog.telephonySelectServer.rememberChoice')} From ce12206d9fcbe92b7fca01d01e989281c1f8c4bd Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Thu, 14 May 2026 08:06:15 -0300 Subject: [PATCH 15/55] Feat/telephony shortcut main process (#3334) * feat(telephony): add global shortcut to dial clipboard number * fix(telephony): harden global shortcut handling * refactor(telephony): share dialpad opener * test(telephony): stabilize shortcut notification click (#3331) * test(telephony): stabilize shortcut notification click (#3333) --- src/app/PersistableValues.spec.ts | 17 + src/app/PersistableValues.ts | 20 +- src/app/selectors.ts | 3 + src/deepLinks/main.spec.ts | 16 + src/deepLinks/main.ts | 144 +---- src/i18n/en.i18n.json | 12 +- src/main.spec.ts | 4 + src/main.ts | 2 + src/store/rootReducer.ts | 8 +- src/telephony/actions.ts | 17 + src/telephony/common.ts | 4 + src/telephony/dialpad.ts | 132 +++++ src/telephony/links.ts | 38 ++ src/telephony/main.spec.ts | 524 ++++++++++++++++++ src/telephony/main.ts | 252 +++++++++ src/telephony/reducers.ts | 92 ++- src/telephony/renderer/preload.spec.ts | 16 + src/telephony/shortcuts.ts | 39 ++ src/ui/components/SettingsView/GeneralTab.tsx | 2 + .../features/TelephonyGlobalShortcut.spec.tsx | 247 +++++++++ .../features/TelephonyGlobalShortcut.tsx | 227 ++++++++ 21 files changed, 1673 insertions(+), 143 deletions(-) create mode 100644 src/app/PersistableValues.spec.ts create mode 100644 src/telephony/common.ts create mode 100644 src/telephony/dialpad.ts create mode 100644 src/telephony/links.ts create mode 100644 src/telephony/main.spec.ts create mode 100644 src/telephony/main.ts create mode 100644 src/telephony/shortcuts.ts create mode 100644 src/ui/components/SettingsView/features/TelephonyGlobalShortcut.spec.tsx create mode 100644 src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx diff --git a/src/app/PersistableValues.spec.ts b/src/app/PersistableValues.spec.ts new file mode 100644 index 0000000000..5c75347462 --- /dev/null +++ b/src/app/PersistableValues.spec.ts @@ -0,0 +1,17 @@ +import { migrations } from './PersistableValues'; + +describe('PersistableValues migrations', () => { + it('adds telephony shortcut config without losing a persisted telephony server', () => { + const before = ({ + telephonyPreferredServer: 'https://chat.example.com', + } as unknown) as Parameters<(typeof migrations)['>=4.14.0']>[0]; + + expect(migrations['>=4.14.0'](before)).toEqual({ + telephonyPreferredServer: 'https://chat.example.com', + telephonyGlobalShortcutConfig: { + enabled: false, + accelerator: null, + }, + }); + }); +}); diff --git a/src/app/PersistableValues.ts b/src/app/PersistableValues.ts index 1fe354ec3b..4d97ac0ee3 100644 --- a/src/app/PersistableValues.ts +++ b/src/app/PersistableValues.ts @@ -2,6 +2,7 @@ import type { Certificate } from 'electron'; import type { Download } from '../downloads/common'; import type { Server } from '../servers/common'; +import type { TelephonyGlobalShortcutConfig } from '../telephony/actions'; import type { WindowState } from '../ui/common'; type PersistableValues_0_0_0 = { @@ -106,9 +107,14 @@ type PersistableValues_4_13_0 = PersistableValues_4_11_0 & { isDebugLoggingEnabled: boolean; }; +type PersistableValues_4_14_0 = PersistableValues_4_13_0 & { + telephonyPreferredServer: string | null; + telephonyGlobalShortcutConfig: TelephonyGlobalShortcutConfig; +}; + export type PersistableValues = Pick< - PersistableValues_4_13_0, - keyof PersistableValues_4_13_0 + PersistableValues_4_14_0, + keyof PersistableValues_4_14_0 >; export const migrations = { @@ -201,4 +207,14 @@ export const migrations = { ...before, isDebugLoggingEnabled: false, }), + '>=4.14.0': (before: PersistableValues_4_13_0): PersistableValues_4_14_0 => ({ + ...before, + telephonyPreferredServer: + (before as Partial).telephonyPreferredServer ?? + null, + telephonyGlobalShortcutConfig: { + enabled: false, + accelerator: null, + }, + }), }; diff --git a/src/app/selectors.ts b/src/app/selectors.ts index 6aa29b24cb..e81dc6255d 100644 --- a/src/app/selectors.ts +++ b/src/app/selectors.ts @@ -85,4 +85,7 @@ export const selectPersistableValues = createStructuredSelector({ isDebugLoggingEnabled, telephonyPreferredServer: ({ telephonyPreferredServer }: RootState) => telephonyPreferredServer, + telephonyGlobalShortcutConfig: ({ + telephonyGlobalShortcutConfig, + }: RootState) => telephonyGlobalShortcutConfig, }); diff --git a/src/deepLinks/main.spec.ts b/src/deepLinks/main.spec.ts index 39d2315321..6fd84bbdd2 100644 --- a/src/deepLinks/main.spec.ts +++ b/src/deepLinks/main.spec.ts @@ -188,6 +188,22 @@ describe('deepLinks/main.ts', () => { expect(listenMock).not.toHaveBeenCalled(); }); + it('should open dialpad path with empty input when requested', async () => { + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + await performTelephonyCall({ phoneNumber: '', rawUri: '' }); + + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + { + phoneNumber: '', + rawUri: '', + } + ); + }); + it('should show dialog when there are 2+ servers and no preference', async () => { selectMock .mockReturnValueOnce([ diff --git a/src/deepLinks/main.ts b/src/deepLinks/main.ts index 08cefc8194..bae9f2d0ea 100644 --- a/src/deepLinks/main.ts +++ b/src/deepLinks/main.ts @@ -7,12 +7,9 @@ import { } from '../app/main/app'; import { ServerUrlResolutionStatus } from '../servers/common'; import { resolveServerUrl } from '../servers/main'; -import { select, dispatch, listen } from '../store'; -import { TELEPHONY_PREFERRED_SERVER_SET } from '../telephony/actions'; -import { - TELEPHONY_SERVER_SELECT_OPEN, - TELEPHONY_SERVER_SELECT_CLOSE, -} from '../ui/actions'; +import { select, dispatch } from '../store'; +import { openTelephonyDialpad } from '../telephony/dialpad'; +import { parseTelephonyLink } from '../telephony/links'; import { askForServerAddition, warnAboutInvalidServerUrl, @@ -21,6 +18,10 @@ import { getRootWindow } from '../ui/main/rootWindow'; import { getWebContentsByServerUrl } from '../ui/main/serverView'; import { DEEP_LINKS_SERVER_FOCUSED, DEEP_LINKS_SERVER_ADDED } from './actions'; +export type { TelephonyLink } from '../telephony/common'; +export { openTelephonyDialpad as performTelephonyCall } from '../telephony/dialpad'; +export { parseTelephonyLink } from '../telephony/links'; + const isDefinedProtocol = (parsedUrl: URL): boolean => parsedUrl.protocol === `${electronBuilderJsonInformation.protocol}:`; @@ -59,135 +60,6 @@ const parseDeepLink = ( return null; }; -const TELEPHONY_PROTOCOLS = ['tel:', 'callto:']; - -export type TelephonyLink = { phoneNumber: string; rawUri: string }; - -export const parseTelephonyLink = (input: string): TelephonyLink | null => { - if (/^--/.test(input)) { - return null; - } - - let url: URL; - - try { - url = new URL(input); - } catch { - return null; - } - - if (!TELEPHONY_PROTOCOLS.includes(url.protocol)) { - return null; - } - - let raw: string; - try { - raw = decodeURIComponent( - url.pathname || url.href.slice(url.protocol.length) - ); - } catch { - return null; - } - - const phoneNumber = raw.replace(/^\/+/, '').replace(/[\s\-().]/g, ''); - - if (!phoneNumber) { - return null; - } - - return { phoneNumber, rawUri: input }; -}; - -const MODAL_TIMEOUT_MS = 120_000; -const WEB_CONTENTS_TIMEOUT_MS = 10_000; - -let telephonyCallInProgress = false; - -export const performTelephonyCall = async ( - link: TelephonyLink -): Promise => { - if (telephonyCallInProgress) { - return; - } - - const servers = select(({ servers }) => servers); - - if (servers.length === 0) { - return; - } - - telephonyCallInProgress = true; - - try { - let serverUrl: string; - - if (servers.length === 1) { - serverUrl = servers[0].url; - } else { - const preferredServer = select( - ({ telephonyPreferredServer }) => telephonyPreferredServer - ); - - if (preferredServer && servers.some((s) => s.url === preferredServer)) { - serverUrl = preferredServer; - } else { - const result = await new Promise<{ - serverUrl: string; - rememberChoice: boolean; - } | null>((resolve) => { - const timeout = setTimeout(() => { - unsubscribe(); - dispatch({ type: TELEPHONY_SERVER_SELECT_CLOSE, payload: null }); - resolve(null); - }, MODAL_TIMEOUT_MS); - - const unsubscribe = listen( - TELEPHONY_SERVER_SELECT_CLOSE, - (action) => { - clearTimeout(timeout); - unsubscribe(); - resolve(action.payload); - } - ); - - dispatch({ - type: TELEPHONY_SERVER_SELECT_OPEN, - payload: { phoneNumber: link.phoneNumber, rawUri: link.rawUri }, - }); - }); - - if (!result) { - return; - } - - serverUrl = result.serverUrl; - - if (result.rememberChoice) { - dispatch({ - type: TELEPHONY_PREFERRED_SERVER_SET, - payload: serverUrl, - }); - } - } - } - - const webContents = await getWebContents( - serverUrl, - WEB_CONTENTS_TIMEOUT_MS - ); - if (!webContents) { - return; - } - - webContents.send('telephony/call-requested', { - phoneNumber: link.phoneNumber, - rawUri: link.rawUri, - }); - } finally { - telephonyCallInProgress = false; - } -}; - export let processDeepLinksInArgs = async (): Promise => undefined; type AuthenticationParams = { @@ -333,7 +205,7 @@ const performConference = async ({ host, path }: InviteParams): Promise => const processDeepLink = async (deepLink: string): Promise => { const telephonyLink = parseTelephonyLink(deepLink); if (telephonyLink) { - await performTelephonyCall(telephonyLink); + await openTelephonyDialpad(telephonyLink); return; } diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index 629d3d7277..d977b888f9 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -299,9 +299,19 @@ }, "telephonyServer": { "title": "Telephony Server", - "description": "Choose which server handles incoming phone calls (tel: and callto: links).", + "description": "Choose which server handles telephony calls from global shortcuts and tel:/callto: links.", "auto": "Auto (ask each time)" }, + "telephonyShortcut": { + "title": "Telephony Global Shortcut", + "description": "Press this shortcut anywhere to focus Rocket.Chat and open the telephony dial pad. Clipboard is read only when the shortcut is pressed.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Press keys...", + "save": "Save", + "clear": "Clear", + "registered": "Shortcut registered", + "reservedAccelerator": "{{accelerator}} is reserved by Rocket.Chat or your operating system." + }, "clearPermittedScreenCaptureServers": { "title": "Clear Screen Capture Permissions", "description": "Clear the screen capture permissions that was selected to not ask again on video calls." diff --git a/src/main.spec.ts b/src/main.spec.ts index c1d7941789..42fc02b1d4 100644 --- a/src/main.spec.ts +++ b/src/main.spec.ts @@ -115,6 +115,10 @@ jest.mock('./spellChecking/main', () => ({ setupSpellChecking: jest.fn(() => Promise.resolve()), })); +jest.mock('./telephony/main', () => ({ + setupTelephonyGlobalShortcut: jest.fn(), +})); + jest.mock('./ui/components/CertificatesManager/main', () => ({ handleCertificatesManager: jest.fn(), })); diff --git a/src/main.ts b/src/main.ts index 3b222c55e6..db551c3c22 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,6 +44,7 @@ import { checkSupportedVersionServers } from './servers/supportedVersions/main'; import { setupSpellChecking } from './spellChecking/main'; import { createMainReduxStore } from './store'; import { applySystemCertificates } from './systemCertificates'; +import { setupTelephonyGlobalShortcut } from './telephony/main'; import { handleCertificatesManager } from './ui/components/CertificatesManager/main'; import dock from './ui/main/dock'; import menuBar from './ui/main/menuBar'; @@ -120,6 +121,7 @@ const start = async (): Promise => { await setupSpellChecking(); setupDeepLinks(); + setupTelephonyGlobalShortcut(); await setupNavigation(); setupPowerMonitor(); await setupUpdates(); diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index f4d8194eeb..8d86ee4265 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -18,7 +18,11 @@ import { allowInsecureOutlookConnections } from '../outlookCalendar/reducers/all import { outlookCalendarSyncInterval } from '../outlookCalendar/reducers/outlookCalendarSyncInterval'; import { outlookCalendarSyncIntervalOverride } from '../outlookCalendar/reducers/outlookCalendarSyncIntervalOverride'; import { servers } from '../servers/reducers'; -import { telephonyPreferredServer } from '../telephony/reducers'; +import { + telephonyGlobalShortcutConfig, + telephonyGlobalShortcutRegistrationStatus, + telephonyPreferredServer, +} from '../telephony/reducers'; import { availableBrowsers } from '../ui/reducers/availableBrowsers'; import { currentView } from '../ui/reducers/currentView'; import { dialogs } from '../ui/reducers/dialogs'; @@ -120,6 +124,8 @@ export const rootReducer = combineReducers({ isTransparentWindowEnabled, isVideoCallScreenCaptureFallbackEnabled, telephonyPreferredServer, + telephonyGlobalShortcutConfig, + telephonyGlobalShortcutRegistrationStatus, }); export type RootState = ReturnType; diff --git a/src/telephony/actions.ts b/src/telephony/actions.ts index 739448af08..5e0d5677b2 100644 --- a/src/telephony/actions.ts +++ b/src/telephony/actions.ts @@ -1,5 +1,22 @@ export const TELEPHONY_PREFERRED_SERVER_SET = 'telephony/preferred-server-set'; +export const TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET = + 'telephony/global-shortcut-config-set'; +export const TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED = + 'telephony/global-shortcut-registration-changed'; + +export type TelephonyGlobalShortcutConfig = { + enabled: boolean; + accelerator: string | null; +}; + +export type TelephonyGlobalShortcutRegistrationStatus = { + registered: boolean; + accelerator: string | null; + error: string | null; +}; export type TelephonyActionTypeToPayloadMap = { [TELEPHONY_PREFERRED_SERVER_SET]: string | null; + [TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET]: TelephonyGlobalShortcutConfig; + [TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED]: TelephonyGlobalShortcutRegistrationStatus; }; diff --git a/src/telephony/common.ts b/src/telephony/common.ts new file mode 100644 index 0000000000..c2b13586e0 --- /dev/null +++ b/src/telephony/common.ts @@ -0,0 +1,4 @@ +export type TelephonyLink = { + phoneNumber: string; + rawUri: string; +}; diff --git a/src/telephony/dialpad.ts b/src/telephony/dialpad.ts new file mode 100644 index 0000000000..e0f2f872f5 --- /dev/null +++ b/src/telephony/dialpad.ts @@ -0,0 +1,132 @@ +import type { WebContents } from 'electron'; + +import { select, dispatch, listen } from '../store'; +import { + TELEPHONY_SERVER_SELECT_OPEN, + TELEPHONY_SERVER_SELECT_CLOSE, +} from '../ui/actions'; +import { getWebContentsByServerUrl } from '../ui/main/serverView'; +import { TELEPHONY_PREFERRED_SERVER_SET } from './actions'; +import type { TelephonyLink } from './common'; + +const MODAL_TIMEOUT_MS = 120_000; +const WEB_CONTENTS_TIMEOUT_MS = 10_000; + +let telephonyDialpadOpenInProgress = false; + +const getTelephonyWebContents = ( + serverUrl: string, + timeoutMs: number +): Promise => + new Promise((resolve) => { + const deadline = Date.now() + timeoutMs; + + const poll = (): void => { + const webContents = getWebContentsByServerUrl(serverUrl); + if (webContents) { + resolve(webContents); + return; + } + + if (Date.now() >= deadline) { + resolve(null); + return; + } + + setTimeout(poll, 100); + }; + + poll(); + }); + +const selectTelephonyServerUrl = async ( + link: TelephonyLink +): Promise => { + const servers = select(({ servers }) => servers); + + if (servers.length === 0) { + return null; + } + + if (servers.length === 1) { + return servers[0].url; + } + + const preferredServer = select( + ({ telephonyPreferredServer }) => telephonyPreferredServer + ); + + if ( + preferredServer && + servers.some((server) => server.url === preferredServer) + ) { + return preferredServer; + } + + const result = await new Promise<{ + serverUrl: string; + rememberChoice: boolean; + } | null>((resolve) => { + const timeout = setTimeout(() => { + unsubscribe(); + dispatch({ type: TELEPHONY_SERVER_SELECT_CLOSE, payload: null }); + resolve(null); + }, MODAL_TIMEOUT_MS); + + const unsubscribe = listen(TELEPHONY_SERVER_SELECT_CLOSE, (action) => { + clearTimeout(timeout); + unsubscribe(); + resolve(action.payload); + }); + + dispatch({ + type: TELEPHONY_SERVER_SELECT_OPEN, + payload: { phoneNumber: link.phoneNumber, rawUri: link.rawUri }, + }); + }); + + if (!result) { + return null; + } + + if (result.rememberChoice) { + dispatch({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: result.serverUrl, + }); + } + + return result.serverUrl; +}; + +export const openTelephonyDialpad = async ( + link: TelephonyLink +): Promise => { + if (telephonyDialpadOpenInProgress) { + return; + } + + telephonyDialpadOpenInProgress = true; + + try { + const serverUrl = await selectTelephonyServerUrl(link); + if (!serverUrl) { + return; + } + + const webContents = await getTelephonyWebContents( + serverUrl, + WEB_CONTENTS_TIMEOUT_MS + ); + if (!webContents) { + return; + } + + webContents.send('telephony/call-requested', { + phoneNumber: link.phoneNumber, + rawUri: link.rawUri, + }); + } finally { + telephonyDialpadOpenInProgress = false; + } +}; diff --git a/src/telephony/links.ts b/src/telephony/links.ts new file mode 100644 index 0000000000..a18c223b6b --- /dev/null +++ b/src/telephony/links.ts @@ -0,0 +1,38 @@ +import type { TelephonyLink } from './common'; + +const TELEPHONY_PROTOCOLS = ['tel:', 'callto:']; + +export const parseTelephonyLink = (input: string): TelephonyLink | null => { + if (/^--/.test(input)) { + return null; + } + + let url: URL; + + try { + url = new URL(input); + } catch { + return null; + } + + if (!TELEPHONY_PROTOCOLS.includes(url.protocol)) { + return null; + } + + let raw: string; + try { + raw = decodeURIComponent( + url.pathname || url.href.slice(url.protocol.length) + ); + } catch { + return null; + } + + const phoneNumber = raw.replace(/^\/+/, '').replace(/[\s\-().]/g, ''); + + if (!phoneNumber) { + return null; + } + + return { phoneNumber, rawUri: input }; +}; diff --git a/src/telephony/main.spec.ts b/src/telephony/main.spec.ts new file mode 100644 index 0000000000..cc06fdb6cf --- /dev/null +++ b/src/telephony/main.spec.ts @@ -0,0 +1,524 @@ +import { app, clipboard, globalShortcut, Notification } from 'electron'; + +import { APP_SETTINGS_LOADED } from '../app/actions'; +import { dispatch, watch } from '../store'; +import { SIDE_BAR_SETTINGS_BUTTON_CLICKED } from '../ui/actions'; +import { getRootWindow } from '../ui/main/rootWindow'; +import { + TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, +} from './actions'; +import type { openTelephonyDialpad } from './dialpad'; +import { parseTelephonyLink } from './links'; +import { + createTelephonyLinkFromClipboardText, + registerTelephonyGlobalShortcut, + setupTelephonyGlobalShortcut, + teardownTelephonyGlobalShortcut, + triggerTelephonyGlobalShortcut, +} from './main'; +import { + defaultTelephonyGlobalShortcutConfig, + defaultTelephonyGlobalShortcutRegistrationStatus, + telephonyGlobalShortcutConfig, + telephonyGlobalShortcutRegistrationStatus, + telephonyPreferredServer, +} from './reducers'; + +jest.mock('electron', () => { + const NotificationMock = jest.fn(() => ({ + addListener: jest.fn(), + show: jest.fn(), + })); + + return { + app: { + addListener: jest.fn(), + removeListener: jest.fn(), + }, + clipboard: { + readText: jest.fn(), + }, + globalShortcut: { + isRegistered: jest.fn(() => false), + register: jest.fn(), + unregister: jest.fn(), + }, + Notification: Object.assign(NotificationMock, { + isSupported: jest.fn(() => true), + }), + }; +}); + +jest.mock('./dialpad', () => ({ + openTelephonyDialpad: jest.fn(() => Promise.resolve()), +})); + +jest.mock('./links', () => ({ + parseTelephonyLink: jest.fn(), +})); + +jest.mock('../logging', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + }, +})); + +jest.mock('../store', () => ({ + dispatch: jest.fn(), + watch: jest.fn(), +})); + +jest.mock('../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(), +})); + +const appMock = app as jest.Mocked; +const clipboardMock = clipboard as jest.Mocked; +const globalShortcutMock = globalShortcut as jest.Mocked; +const notificationMock = Notification as jest.Mocked; +const getOpenTelephonyDialpadMock = (): jest.MockedFunction< + typeof openTelephonyDialpad +> => { + const dialpad = jest.requireMock('./dialpad') as { + openTelephonyDialpad: jest.MockedFunction; + }; + return dialpad.openTelephonyDialpad; +}; +const parseTelephonyLinkMock = parseTelephonyLink as jest.MockedFunction< + typeof parseTelephonyLink +>; +const dispatchMock = dispatch as jest.MockedFunction; +const watchMock = watch as jest.MockedFunction; +const getRootWindowMock = getRootWindow as jest.MockedFunction< + typeof getRootWindow +>; + +describe('telephony global shortcut main process pipeline', () => { + const rootWindow = { + isVisible: jest.fn(() => false), + showInactive: jest.fn(), + focus: jest.fn(), + }; + + beforeEach(() => { + teardownTelephonyGlobalShortcut(); + jest.clearAllMocks(); + parseTelephonyLinkMock.mockReturnValue(null); + getRootWindowMock.mockResolvedValue(rootWindow as any); + globalShortcutMock.isRegistered.mockReturnValue(false); + globalShortcutMock.register.mockReturnValue(true); + }); + + afterEach(() => { + teardownTelephonyGlobalShortcut(); + }); + + it('registers the configured accelerator and reports success', () => { + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + + expect(globalShortcutMock.register).toHaveBeenCalledWith( + 'CommandOrControl+Shift+D', + expect.any(Function) + ); + expect(dispatchMock).toHaveBeenLastCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: true, + accelerator: 'CommandOrControl+Shift+D', + error: null, + }, + }); + }); + + it('reads clipboard only when triggered and routes usable clipboard text', async () => { + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + + expect(clipboardMock.readText).not.toHaveBeenCalled(); + + clipboardMock.readText.mockReturnValue(' +1 (800) 555-0199 '); + + await triggerTelephonyGlobalShortcut(); + + expect(rootWindow.showInactive).toHaveBeenCalled(); + expect(rootWindow.focus).toHaveBeenCalled(); + expect(getOpenTelephonyDialpadMock()).toHaveBeenCalledWith({ + phoneNumber: '+1 (800) 555-0199', + rawUri: '+1 (800) 555-0199', + }); + }); + + it('keeps common pasted phone number formats permissive', () => { + expect( + createTelephonyLinkFromClipboardText('Call +1 (800) 555-0199 x123') + ).toEqual({ + phoneNumber: 'Call +1 (800) 555-0199 x123', + rawUri: 'Call +1 (800) 555-0199 x123', + }); + }); + + it('opens the telephony path with empty input when clipboard is unusable', async () => { + clipboardMock.readText.mockReturnValue('not a phone number'); + + await triggerTelephonyGlobalShortcut(); + + expect(getOpenTelephonyDialpadMock()).toHaveBeenCalledWith({ + phoneNumber: '', + rawUri: '', + }); + }); + + it('skips parsing and opens empty input for empty clipboard text', () => { + expect(createTelephonyLinkFromClipboardText(' ')).toEqual({ + phoneNumber: '', + rawUri: '', + }); + expect(parseTelephonyLinkMock).not.toHaveBeenCalled(); + }); + + it('caps clipboard text before parsing or sending it to the renderer', () => { + expect(createTelephonyLinkFromClipboardText('1'.repeat(257))).toEqual({ + phoneNumber: '', + rawUri: '', + }); + expect(parseTelephonyLinkMock).not.toHaveBeenCalled(); + }); + + it('debounces repeated shortcut triggers', async () => { + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(1_100) + .mockReturnValueOnce(1_300); + clipboardMock.readText.mockReturnValue('+1 800 555 0199'); + + await triggerTelephonyGlobalShortcut(); + await triggerTelephonyGlobalShortcut(); + await triggerTelephonyGlobalShortcut(); + + expect(clipboardMock.readText).toHaveBeenCalledTimes(2); + expect(getOpenTelephonyDialpadMock()).toHaveBeenCalledTimes(2); + nowSpy.mockRestore(); + }); + + it('preserves parsed tel/callto links from clipboard', () => { + parseTelephonyLinkMock.mockReturnValue({ + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + }); + + expect(createTelephonyLinkFromClipboardText(' tel:+491234567890 ')).toEqual( + { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + } + ); + }); + + it('unregisters old accelerator when disabled or changed', () => { + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+E', + }); + + expect(globalShortcutMock.unregister).toHaveBeenCalledWith( + 'CommandOrControl+Shift+D' + ); + + registerTelephonyGlobalShortcut({ enabled: false, accelerator: null }); + + expect(globalShortcutMock.unregister).toHaveBeenCalledWith( + 'CommandOrControl+Shift+E' + ); + }); + + it('ignores malformed persisted config without throwing', () => { + expect(() => registerTelephonyGlobalShortcut(null)).not.toThrow(); + + expect(globalShortcutMock.register).not.toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenLastCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: false, + accelerator: null, + error: null, + }, + }); + }); + + it('handles registration conflicts without throwing and shows feedback', () => { + globalShortcutMock.register.mockReturnValue(false); + + expect(() => + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }) + ).not.toThrow(); + + expect(dispatchMock).toHaveBeenLastCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: false, + accelerator: 'CommandOrControl+Shift+D', + error: + 'Telephony shortcut CommandOrControl+Shift+D registration failed', + }, + }); + expect(notificationMock.isSupported).toHaveBeenCalled(); + expect(notificationMock).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('could not be registered'), + }) + ); + expect( + (notificationMock as unknown as jest.Mock).mock.results[0].value.show + ).toHaveBeenCalled(); + }); + + it('opens Settings when the registration failure notification is clicked', async () => { + globalShortcutMock.register.mockReturnValue(false); + + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + + const notification = (notificationMock as unknown as jest.Mock).mock + .results[0].value; + const clickListener = notification.addListener.mock.calls.find( + ([event]: [string]) => event === 'click' + )?.[1] as (() => Promise) | undefined; + + await clickListener?.(); + + expect(rootWindow.focus).toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenCalledWith({ + type: SIDE_BAR_SETTINGS_BUTTON_CLICKED, + }); + }); + + it('rejects reserved app accelerators before registering', () => { + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+C', + }); + + expect(globalShortcutMock.register).not.toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenLastCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: false, + accelerator: 'CommandOrControl+C', + error: + 'Telephony shortcut CommandOrControl+C is reserved by the app or operating system', + }, + }); + }); + + it('reports accelerators already registered by Electron before registering', () => { + globalShortcutMock.isRegistered.mockReturnValue(true); + + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + + expect(globalShortcutMock.register).not.toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenLastCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: false, + accelerator: 'CommandOrControl+Shift+D', + error: + 'Telephony shortcut CommandOrControl+Shift+D is already registered', + }, + }); + }); + + it('watches config changes and unregisters on app close teardown', () => { + const unsubscribe = jest.fn(); + let watcher: Parameters[1] | undefined; + + watchMock.mockImplementation((_selector, callback) => { + watcher = callback as typeof watcher; + callback( + { enabled: true, accelerator: 'CommandOrControl+Shift+D' }, + undefined + ); + return unsubscribe; + }); + + setupTelephonyGlobalShortcut(); + + expect(appMock.addListener).toHaveBeenCalledWith( + 'will-quit', + teardownTelephonyGlobalShortcut + ); + expect(globalShortcutMock.register).toHaveBeenCalledWith( + 'CommandOrControl+Shift+D', + expect.any(Function) + ); + + watcher?.( + { enabled: true, accelerator: 'CommandOrControl+Shift+E' }, + { enabled: true, accelerator: 'CommandOrControl+Shift+D' } + ); + + expect(globalShortcutMock.unregister).toHaveBeenCalledWith( + 'CommandOrControl+Shift+D' + ); + + teardownTelephonyGlobalShortcut(); + + expect(unsubscribe).toHaveBeenCalled(); + expect(appMock.removeListener).toHaveBeenCalledWith( + 'will-quit', + teardownTelephonyGlobalShortcut + ); + expect(globalShortcutMock.unregister).toHaveBeenCalledWith( + 'CommandOrControl+Shift+E' + ); + }); + + it('unregisters the current accelerator when Electron emits will-quit', () => { + let willQuitHandler: (() => void) | undefined; + appMock.addListener.mockImplementation(((event: string, listener) => { + if (event === 'will-quit') { + willQuitHandler = listener as () => void; + } + return appMock; + }) as typeof appMock.addListener); + watchMock.mockImplementation((_selector, callback) => { + callback( + { enabled: true, accelerator: 'CommandOrControl+Shift+D' }, + undefined + ); + return jest.fn(); + }); + + setupTelephonyGlobalShortcut(); + willQuitHandler?.(); + + expect(globalShortcutMock.unregister).toHaveBeenCalledWith( + 'CommandOrControl+Shift+D' + ); + }); +}); + +describe('telephony shortcut reducers', () => { + it('hydrates preferred server from persisted settings', () => { + expect( + telephonyPreferredServer(null, { + type: APP_SETTINGS_LOADED, + payload: { + telephonyPreferredServer: 'https://chat.example.com', + }, + }) + ).toBe('https://chat.example.com'); + }); + + it('keeps shortcut config disabled by default and stores UI-provided config', () => { + expect( + telephonyGlobalShortcutConfig(undefined, { type: 'UNKNOWN' } as any) + ).toEqual(defaultTelephonyGlobalShortcutConfig); + + expect( + telephonyGlobalShortcutConfig(defaultTelephonyGlobalShortcutConfig, { + type: TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + payload: { enabled: true, accelerator: 'CommandOrControl+Shift+D' }, + }) + ).toEqual({ enabled: true, accelerator: 'CommandOrControl+Shift+D' }); + }); + + it('hydrates shortcut config from persisted settings', () => { + expect( + telephonyGlobalShortcutConfig(defaultTelephonyGlobalShortcutConfig, { + type: APP_SETTINGS_LOADED, + payload: { + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }, + }, + }) + ).toEqual({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + }); + + it('normalizes malformed persisted shortcut config', () => { + expect( + telephonyGlobalShortcutConfig(defaultTelephonyGlobalShortcutConfig, { + type: APP_SETTINGS_LOADED, + payload: { + telephonyGlobalShortcutConfig: null as any, + }, + }) + ).toEqual(defaultTelephonyGlobalShortcutConfig); + }); + + it('rejects non-string and oversized persisted shortcut accelerators', () => { + expect( + telephonyGlobalShortcutConfig(defaultTelephonyGlobalShortcutConfig, { + type: APP_SETTINGS_LOADED, + payload: { + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 123 as any, + }, + }, + }) + ).toEqual(defaultTelephonyGlobalShortcutConfig); + + expect( + telephonyGlobalShortcutConfig(defaultTelephonyGlobalShortcutConfig, { + type: APP_SETTINGS_LOADED, + payload: { + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 'A'.repeat(65), + }, + }, + }) + ).toEqual(defaultTelephonyGlobalShortcutConfig); + }); + + it('stores registration status for Settings UI feedback', () => { + expect( + telephonyGlobalShortcutRegistrationStatus(undefined, { + type: 'UNKNOWN', + } as any) + ).toBe(defaultTelephonyGlobalShortcutRegistrationStatus); + + expect( + telephonyGlobalShortcutRegistrationStatus( + defaultTelephonyGlobalShortcutRegistrationStatus, + { + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: false, + accelerator: 'CommandOrControl+Shift+D', + error: 'conflict', + }, + } + ) + ).toEqual({ + registered: false, + accelerator: 'CommandOrControl+Shift+D', + error: 'conflict', + }); + }); +}); diff --git a/src/telephony/main.ts b/src/telephony/main.ts new file mode 100644 index 0000000000..1f1e1a9fb3 --- /dev/null +++ b/src/telephony/main.ts @@ -0,0 +1,252 @@ +import { app, clipboard, globalShortcut, Notification } from 'electron'; + +import { logger } from '../logging'; +import { dispatch, watch } from '../store'; +import type { RootState } from '../store/rootReducer'; +import { SIDE_BAR_SETTINGS_BUTTON_CLICKED } from '../ui/actions'; +import { getRootWindow } from '../ui/main/rootWindow'; +import { TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED } from './actions'; +import type { TelephonyGlobalShortcutConfig } from './actions'; +import type { TelephonyLink } from './common'; +import { parseTelephonyLink } from './links'; +import { + MAX_CLIPBOARD_PHONE_LENGTH, + isReservedTelephonyShortcutAccelerator, + normalizeTelephonyShortcutAccelerator, +} from './shortcuts'; + +const selectTelephonyGlobalShortcutConfig = ({ + telephonyGlobalShortcutConfig, +}: RootState): TelephonyGlobalShortcutConfig => telephonyGlobalShortcutConfig; + +let registeredAccelerator: string | null = null; +let unsubscribeFromShortcutConfig: (() => void) | null = null; +let lastTelephonyShortcutTriggeredAt = 0; + +const TELEPHONY_GLOBAL_SHORTCUT_DEBOUNCE_MS = 250; + +const EMPTY_TELEPHONY_LINK: TelephonyLink = { + phoneNumber: '', + rawUri: '', +}; + +const DISABLED_SHORTCUT_CONFIG: TelephonyGlobalShortcutConfig = { + enabled: false, + accelerator: null, +}; + +const normalizeTelephonyGlobalShortcutConfig = ( + config: TelephonyGlobalShortcutConfig | null | undefined +): TelephonyGlobalShortcutConfig => { + if (!config || typeof config !== 'object') { + return DISABLED_SHORTCUT_CONFIG; + } + + const accelerator = normalizeTelephonyShortcutAccelerator(config.accelerator); + + return { + enabled: config.enabled === true, + accelerator, + }; +}; + +const extractClipboardPhoneNumber = (text: string): string | null => { + const trimmedText = text.trim(); + const digitCount = (trimmedText.match(/\d/g) ?? []).length; + + if (digitCount < 3) { + return null; + } + + return trimmedText; +}; + +export const createTelephonyLinkFromClipboardText = ( + text: string +): TelephonyLink => { + const trimmedText = text.trim(); + if (!trimmedText || trimmedText.length > MAX_CLIPBOARD_PHONE_LENGTH) { + return EMPTY_TELEPHONY_LINK; + } + + const telephonyLink = parseTelephonyLink(trimmedText); + if (telephonyLink) { + return telephonyLink; + } + + const phoneNumber = extractClipboardPhoneNumber(trimmedText); + if (!phoneNumber) { + return EMPTY_TELEPHONY_LINK; + } + + return { + phoneNumber, + rawUri: trimmedText, + }; +}; + +const focusRootWindow = async (): Promise => { + const browserWindow = await getRootWindow(); + + if (!browserWindow.isVisible()) { + browserWindow.showInactive(); + } + + browserWindow.focus(); +}; + +export const triggerTelephonyGlobalShortcut = async (): Promise => { + const now = Date.now(); + if ( + lastTelephonyShortcutTriggeredAt && + now - lastTelephonyShortcutTriggeredAt < + TELEPHONY_GLOBAL_SHORTCUT_DEBOUNCE_MS + ) { + return; + } + lastTelephonyShortcutTriggeredAt = now; + + const telephonyLink = createTelephonyLinkFromClipboardText( + clipboard.readText() + ); + + const { openTelephonyDialpad } = await import('./dialpad'); + + await focusRootWindow(); + await openTelephonyDialpad(telephonyLink); +}; + +const notifyRegistrationFailure = ( + accelerator: string, + error: string +): void => { + logger.warn(error); + + try { + if (!Notification.isSupported()) { + return; + } + + const notification = new Notification({ + title: 'Rocket.Chat', + body: `Telephony shortcut ${accelerator} could not be registered. It may already be in use.`, + }); + notification.addListener('click', () => + focusRootWindow() + .catch((error) => { + logger.warn( + 'Failed to focus Rocket.Chat from telephony shortcut notification' + ); + logger.warn(error); + }) + .finally(() => { + dispatch({ type: SIDE_BAR_SETTINGS_BUTTON_CLICKED }); + }) + ); + notification.show(); + } catch (notificationError) { + logger.warn('Failed to show telephony shortcut registration feedback'); + logger.warn(notificationError); + } +}; + +const dispatchRegistrationStatus = ( + registered: boolean, + accelerator: string | null, + error: string | null +): void => { + dispatch({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered, + accelerator, + error, + }, + }); +}; + +export const unregisterTelephonyGlobalShortcut = (): void => { + if (registeredAccelerator) { + globalShortcut.unregister(registeredAccelerator); + registeredAccelerator = null; + } + + dispatchRegistrationStatus(false, null, null); +}; + +export const registerTelephonyGlobalShortcut = ( + config: TelephonyGlobalShortcutConfig | null | undefined +): void => { + const { enabled, accelerator } = + normalizeTelephonyGlobalShortcutConfig(config); + + unregisterTelephonyGlobalShortcut(); + + if (!enabled || !accelerator) { + return; + } + + try { + if (isReservedTelephonyShortcutAccelerator(accelerator)) { + const error = `Telephony shortcut ${accelerator} is reserved by the app or operating system`; + dispatchRegistrationStatus(false, accelerator, error); + notifyRegistrationFailure(accelerator, error); + return; + } + + if (globalShortcut.isRegistered?.(accelerator)) { + const error = `Telephony shortcut ${accelerator} is already registered`; + dispatchRegistrationStatus(false, accelerator, error); + notifyRegistrationFailure(accelerator, error); + return; + } + + const registered = globalShortcut.register(accelerator, () => { + void triggerTelephonyGlobalShortcut().catch((error) => { + logger.error('Failed to handle telephony global shortcut', error); + }); + }); + + if (!registered) { + const error = `Telephony shortcut ${accelerator} registration failed`; + dispatchRegistrationStatus(false, accelerator, error); + notifyRegistrationFailure(accelerator, error); + return; + } + + registeredAccelerator = accelerator; + dispatchRegistrationStatus(true, accelerator, null); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const failureMessage = `Telephony shortcut ${accelerator} registration failed: ${message}`; + dispatchRegistrationStatus(false, accelerator, failureMessage); + notifyRegistrationFailure(accelerator, failureMessage); + } +}; + +export const setupTelephonyGlobalShortcut = (): void => { + if (unsubscribeFromShortcutConfig) { + return; + } + + unsubscribeFromShortcutConfig = watch( + selectTelephonyGlobalShortcutConfig, + (config) => { + registerTelephonyGlobalShortcut(config); + } + ); + + app.addListener('will-quit', teardownTelephonyGlobalShortcut); +}; + +export const teardownTelephonyGlobalShortcut = (): void => { + app.removeListener('will-quit', teardownTelephonyGlobalShortcut); + lastTelephonyShortcutTriggeredAt = 0; + + if (unsubscribeFromShortcutConfig) { + unsubscribeFromShortcutConfig(); + unsubscribeFromShortcutConfig = null; + } + + unregisterTelephonyGlobalShortcut(); +}; diff --git a/src/telephony/reducers.ts b/src/telephony/reducers.ts index b17c793716..407073c31c 100644 --- a/src/telephony/reducers.ts +++ b/src/telephony/reducers.ts @@ -1,12 +1,60 @@ import type { Reducer } from 'redux'; +import { APP_SETTINGS_LOADED } from '../app/actions'; import type { ActionOf } from '../store/actions'; -import { TELEPHONY_PREFERRED_SERVER_SET } from './actions'; +import { + TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + TELEPHONY_PREFERRED_SERVER_SET, +} from './actions'; +import type { + TelephonyGlobalShortcutConfig, + TelephonyGlobalShortcutRegistrationStatus, +} from './actions'; +import { normalizeTelephonyShortcutAccelerator } from './shortcuts'; -type TelephonyPreferredServerAction = ActionOf< - typeof TELEPHONY_PREFERRED_SERVER_SET +type TelephonyPreferredServerAction = + | ActionOf + | ActionOf; + +type TelephonyGlobalShortcutConfigAction = + | ActionOf + | ActionOf; + +type TelephonyGlobalShortcutRegistrationStatusAction = ActionOf< + typeof TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED >; +export const defaultTelephonyGlobalShortcutConfig: TelephonyGlobalShortcutConfig = + { + enabled: false, + accelerator: null, + }; + +export const defaultTelephonyGlobalShortcutRegistrationStatus: TelephonyGlobalShortcutRegistrationStatus = + { + registered: false, + accelerator: null, + error: null, + }; + +const normalizeTelephonyGlobalShortcutConfig = ( + config: Partial | null | undefined +): TelephonyGlobalShortcutConfig => { + if (!config || typeof config !== 'object') { + return defaultTelephonyGlobalShortcutConfig; + } + + const accelerator = normalizeTelephonyShortcutAccelerator( + config.accelerator + ); + + return { + enabled: config.enabled === true && Boolean(accelerator), + accelerator, + }; +}; + export const telephonyPreferredServer: Reducer< string | null, TelephonyPreferredServerAction @@ -15,6 +63,44 @@ export const telephonyPreferredServer: Reducer< case TELEPHONY_PREFERRED_SERVER_SET: return action.payload; + case APP_SETTINGS_LOADED: { + const { telephonyPreferredServer = state } = action.payload; + return telephonyPreferredServer; + } + + default: + return state; + } +}; + +export const telephonyGlobalShortcutConfig: Reducer< + TelephonyGlobalShortcutConfig, + TelephonyGlobalShortcutConfigAction +> = (state = defaultTelephonyGlobalShortcutConfig, action) => { + switch (action.type) { + case TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET: + return normalizeTelephonyGlobalShortcutConfig(action.payload); + + case APP_SETTINGS_LOADED: { + const { telephonyGlobalShortcutConfig = state } = action.payload; + return normalizeTelephonyGlobalShortcutConfig( + telephonyGlobalShortcutConfig + ); + } + + default: + return state; + } +}; + +export const telephonyGlobalShortcutRegistrationStatus: Reducer< + TelephonyGlobalShortcutRegistrationStatus, + TelephonyGlobalShortcutRegistrationStatusAction +> = (state = defaultTelephonyGlobalShortcutRegistrationStatus, action) => { + switch (action.type) { + case TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED: + return action.payload; + default: return state; } diff --git a/src/telephony/renderer/preload.spec.ts b/src/telephony/renderer/preload.spec.ts index 96eebb1509..e5d31df5ea 100644 --- a/src/telephony/renderer/preload.spec.ts +++ b/src/telephony/renderer/preload.spec.ts @@ -105,6 +105,22 @@ describe('telephony/preload', () => { expect(cb).toHaveBeenCalledWith(payload); }); + it('delivers empty phone payloads so the renderer can open an empty dial pad', () => { + listenToTelephonyRequests(); + + const cb = jest.fn(); + onTelephonyCallRequested(cb); + + const payload: TelephonyPayload = { + phoneNumber: '', + rawUri: '', + }; + fireIpcEvent(payload); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(payload); + }); + it('replacing callback: next IPC event fires the new callback only', () => { listenToTelephonyRequests(); diff --git a/src/telephony/shortcuts.ts b/src/telephony/shortcuts.ts new file mode 100644 index 0000000000..7f5de780fe --- /dev/null +++ b/src/telephony/shortcuts.ts @@ -0,0 +1,39 @@ +export const MAX_CLIPBOARD_PHONE_LENGTH = 256; +export const MAX_TELEPHONY_SHORTCUT_ACCELERATOR_LENGTH = 64; + +const normalizeAccelerator = (accelerator: string): string => + accelerator + .replace(/\s+/g, '') + .replace(/cmd/gi, 'command') + .replace(/ctrl/gi, 'control') + .toLowerCase(); + +const RESERVED_ACCELERATORS = new Set( + ['C', 'V', 'X', 'A', 'Z', 'Q', 'W', 'N', ','].flatMap((key) => [ + `commandorcontrol+${key.toLowerCase()}`, + `command+${key.toLowerCase()}`, + `control+${key.toLowerCase()}`, + ]) +); + +export const normalizeTelephonyShortcutAccelerator = ( + accelerator: unknown +): string | null => { + if (typeof accelerator !== 'string') { + return null; + } + + const trimmedAccelerator = accelerator.trim(); + if ( + !trimmedAccelerator || + trimmedAccelerator.length > MAX_TELEPHONY_SHORTCUT_ACCELERATOR_LENGTH + ) { + return null; + } + + return trimmedAccelerator; +}; + +export const isReservedTelephonyShortcutAccelerator = ( + accelerator: string +): boolean => RESERVED_ACCELERATORS.has(normalizeAccelerator(accelerator)); diff --git a/src/ui/components/SettingsView/GeneralTab.tsx b/src/ui/components/SettingsView/GeneralTab.tsx index 3c1d6b9199..4d696d6436 100644 --- a/src/ui/components/SettingsView/GeneralTab.tsx +++ b/src/ui/components/SettingsView/GeneralTab.tsx @@ -12,6 +12,7 @@ import { OutlookCalendarSyncInterval } from './features/OutlookCalendarSyncInter import { ReportErrors } from './features/ReportErrors'; import { ScreenCaptureFallback } from './features/ScreenCaptureFallback'; import { SideBar } from './features/SideBar'; +import { TelephonyGlobalShortcut } from './features/TelephonyGlobalShortcut'; import { TelephonyServer } from './features/TelephonyServer'; import { ThemeAppearance } from './features/ThemeAppearance'; import { TransparentWindow } from './features/TransparentWindow'; @@ -36,6 +37,7 @@ export const GeneralTab = () => ( + {!process.mas && } diff --git a/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.spec.tsx b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.spec.tsx new file mode 100644 index 0000000000..1bef7945a6 --- /dev/null +++ b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.spec.tsx @@ -0,0 +1,247 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import type { RootState } from '../../../../store/rootReducer'; +import { TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET } from '../../../../telephony/actions'; +import { TelephonyGlobalShortcut } from './TelephonyGlobalShortcut'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@rocket.chat/fuselage', () => ({ + Box: ({ + children, + is: component = 'div', + display: _display, + alignItems: _alignItems, + flexGrow: _flexGrow, + ...props + }: any) => { + const Component = component; + return {children}; + }, + Button: ({ children, mis: _mis, ...props }: any) => ( + + ), + Field: ({ children }: any) =>
{children}
, + FieldHint: ({ children, ...props }: any) =>
{children}
, + FieldLabel: ({ children, ...props }: any) => ( + + ), + FieldRow: ({ children }: any) =>
{children}
, + TextInput: (props: any) => , +})); + +type PartialState = Pick< + RootState, + 'telephonyGlobalShortcutConfig' | 'telephonyGlobalShortcutRegistrationStatus' +>; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +const defaultState: PartialState = { + telephonyGlobalShortcutConfig: { + enabled: false, + accelerator: null, + }, + telephonyGlobalShortcutRegistrationStatus: { + registered: false, + accelerator: null, + error: null, + }, +}; + +describe('TelephonyGlobalShortcut', () => { + it('saves a manually entered accelerator', () => { + const store = makeStore(defaultState); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + fireEvent.change(screen.getByTestId('telephony-shortcut-input'), { + target: { value: 'CommandOrControl+Shift+D' }, + }); + fireEvent.click(screen.getByTestId('telephony-shortcut-save')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + payload: { + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }, + }); + }); + + it('captures a pressed key chord into Electron accelerator syntax', () => { + const store = makeStore(defaultState); + + render( + + + + ); + + const input = screen.getByTestId('telephony-shortcut-input'); + fireEvent.keyDown(input, { + key: 'd', + ctrlKey: true, + shiftKey: true, + }); + + expect(input).toHaveValue('CommandOrControl+Shift+D'); + }); + + it('shows capture placeholder while the shortcut input is focused', () => { + const store = makeStore(defaultState); + + render( + + + + ); + + const input = screen.getByTestId('telephony-shortcut-input'); + expect(input).toHaveAttribute( + 'placeholder', + 'settings.options.telephonyShortcut.placeholder' + ); + + fireEvent.focus(input); + expect(input).toHaveAttribute( + 'placeholder', + 'settings.options.telephonyShortcut.capturePlaceholder' + ); + + fireEvent.blur(input); + expect(input).toHaveAttribute( + 'placeholder', + 'settings.options.telephonyShortcut.placeholder' + ); + }); + + it('clears the accelerator and disables registration', () => { + const store = makeStore({ + ...defaultState, + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }, + }); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('telephony-shortcut-clear')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + payload: { + enabled: false, + accelerator: null, + }, + }); + }); + + it('does not save reserved copy/paste accelerators', () => { + const store = makeStore(defaultState); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + fireEvent.change(screen.getByTestId('telephony-shortcut-input'), { + target: { value: 'CommandOrControl+C' }, + }); + fireEvent.click(screen.getByTestId('telephony-shortcut-save')); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect( + screen.getByText('settings.options.telephonyShortcut.reservedAccelerator') + ).toBeInTheDocument(); + }); + + it('does not save accelerators used by the app menu', () => { + const store = makeStore(defaultState); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + fireEvent.change(screen.getByTestId('telephony-shortcut-input'), { + target: { value: 'CommandOrControl+N' }, + }); + fireEvent.click(screen.getByTestId('telephony-shortcut-save')); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('restores the saved accelerator when capture is cancelled with Escape', () => { + const store = makeStore({ + ...defaultState, + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }, + }); + + render( + + + + ); + + const input = screen.getByTestId('telephony-shortcut-input'); + fireEvent.change(input, { + target: { value: 'CommandOrControl+Shift+E' }, + }); + fireEvent.keyDown(input, { key: 'Escape' }); + + expect(input).toHaveValue('CommandOrControl+Shift+D'); + expect(input).toHaveAttribute( + 'placeholder', + 'settings.options.telephonyShortcut.placeholder' + ); + }); + + it('shows registration failure feedback from main process status', () => { + const store = makeStore({ + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }, + telephonyGlobalShortcutRegistrationStatus: { + registered: false, + accelerator: 'CommandOrControl+Shift+D', + error: 'Shortcut already in use', + }, + }); + + render( + + + + ); + + expect(screen.getByText('Shortcut already in use')).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx new file mode 100644 index 0000000000..cf5a746477 --- /dev/null +++ b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx @@ -0,0 +1,227 @@ +import { + Box, + Button, + Field, + FieldHint, + FieldLabel, + FieldRow, + TextInput, +} from '@rocket.chat/fuselage'; +import type { ChangeEvent, KeyboardEvent } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import type { Dispatch } from 'redux'; + +import type { RootAction } from '../../../../store/actions'; +import type { RootState } from '../../../../store/rootReducer'; +import { TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET } from '../../../../telephony/actions'; +import { + isReservedTelephonyShortcutAccelerator, + normalizeTelephonyShortcutAccelerator, +} from '../../../../telephony/shortcuts'; + +const normalizeShortcutText = (value: string): string | null => + normalizeTelephonyShortcutAccelerator(value); + +const keyToAcceleratorPart = (key: string): string | null => { + if (['Control', 'Meta', 'Shift', 'Alt'].includes(key)) { + return null; + } + + if (key === ' ') { + return 'Space'; + } + + if (/^[a-z]$/i.test(key)) { + return key.toUpperCase(); + } + + return key.length === 1 ? key.toUpperCase() : key; +}; + +const eventToAccelerator = (event: KeyboardEvent) => { + const key = keyToAcceleratorPart(event.key); + if (!key) { + return null; + } + + const parts = []; + + if (event.ctrlKey || event.metaKey) { + parts.push('CommandOrControl'); + } + + if (event.altKey) { + parts.push('Alt'); + } + + if (event.shiftKey) { + parts.push('Shift'); + } + + parts.push(key); + + return parts.join('+'); +}; + +export const TelephonyGlobalShortcut = () => { + const { t } = useTranslation(); + const dispatch = useDispatch>(); + const telephonyGlobalShortcutConfig = useSelector( + ({ telephonyGlobalShortcutConfig }: RootState) => + telephonyGlobalShortcutConfig + ); + const telephonyGlobalShortcutRegistrationStatus = useSelector( + ({ telephonyGlobalShortcutRegistrationStatus }: RootState) => + telephonyGlobalShortcutRegistrationStatus + ); + const [draftAccelerator, setDraftAccelerator] = useState( + telephonyGlobalShortcutConfig.accelerator ?? '' + ); + const [isCapturingShortcut, setIsCapturingShortcut] = useState(false); + const [validationError, setValidationError] = useState(null); + + useEffect(() => { + setDraftAccelerator(telephonyGlobalShortcutConfig.accelerator ?? ''); + }, [telephonyGlobalShortcutConfig.accelerator]); + + const saveShortcut = useCallback( + (value: string) => { + const accelerator = normalizeShortcutText(value); + if (accelerator && isReservedTelephonyShortcutAccelerator(accelerator)) { + setValidationError( + t('settings.options.telephonyShortcut.reservedAccelerator', { + accelerator, + }) + ); + return; + } + + setValidationError(null); + dispatch({ + type: TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + payload: { + enabled: Boolean(accelerator), + accelerator, + }, + }); + }, + [dispatch, t] + ); + + const handleChange = useCallback((event: ChangeEvent) => { + setDraftAccelerator(event.currentTarget.value); + }, []); + + const handleFocus = useCallback(() => { + setIsCapturingShortcut(true); + }, []); + + const handleBlur = useCallback(() => { + setIsCapturingShortcut(false); + }, []); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const accelerator = eventToAccelerator(event); + if (event.key === 'Escape') { + event.preventDefault(); + setDraftAccelerator(telephonyGlobalShortcutConfig.accelerator ?? ''); + setIsCapturingShortcut(false); + setValidationError(null); + return; + } + + if (!accelerator) { + return; + } + + event.preventDefault(); + setDraftAccelerator(accelerator); + setIsCapturingShortcut(false); + setValidationError(null); + }, + [telephonyGlobalShortcutConfig.accelerator] + ); + + const handleSave = useCallback(() => { + saveShortcut(draftAccelerator); + }, [draftAccelerator, saveShortcut]); + + const handleClear = useCallback(() => { + setDraftAccelerator(''); + saveShortcut(''); + }, [saveShortcut]); + + const isRegistered = + telephonyGlobalShortcutConfig.enabled && + telephonyGlobalShortcutRegistrationStatus.registered && + telephonyGlobalShortcutRegistrationStatus.accelerator === + telephonyGlobalShortcutConfig.accelerator; + + return ( + + + {t('settings.options.telephonyShortcut.title')} + + + + {t('settings.options.telephonyShortcut.description')} + + + + + + + + + + {telephonyGlobalShortcutRegistrationStatus.error && ( + + + {telephonyGlobalShortcutRegistrationStatus.error} + + + )} + {validationError && ( + + {validationError} + + )} + {isRegistered && ( + + + {t('settings.options.telephonyShortcut.registered')} + + + )} + + ); +}; From 9025556fbb156b25ed2a78acf653538ea6135c85 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Thu, 14 May 2026 08:07:29 -0300 Subject: [PATCH 16/55] Add telephony clipboard dial shortcut (#3330) * feat(telephony): add global shortcut to dial clipboard number * fix(telephony): harden global shortcut handling * refactor(telephony): share dialpad opener * test(telephony): stabilize shortcut notification click From 029e20b910b9341ba43d52bf9a4610a88f088c80 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Thu, 14 May 2026 10:05:43 -0300 Subject: [PATCH 17/55] fix telephony deeplink edge cases --- src/app/PersistableValues.spec.ts | 4 +- src/deepLinks/main.spec.ts | 16 ++++++++ src/telephony/links.ts | 9 ++-- src/telephony/reducers.ts | 4 +- .../TelephonyServerSelectModal/index.spec.tsx | 41 +++++++++++++++++++ .../TelephonyServerSelectModal/index.tsx | 8 +++- 6 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/app/PersistableValues.spec.ts b/src/app/PersistableValues.spec.ts index 5c75347462..b7d3fa4def 100644 --- a/src/app/PersistableValues.spec.ts +++ b/src/app/PersistableValues.spec.ts @@ -2,9 +2,9 @@ import { migrations } from './PersistableValues'; describe('PersistableValues migrations', () => { it('adds telephony shortcut config without losing a persisted telephony server', () => { - const before = ({ + const before = { telephonyPreferredServer: 'https://chat.example.com', - } as unknown) as Parameters<(typeof migrations)['>=4.14.0']>[0]; + } as unknown as Parameters<(typeof migrations)['>=4.14.0']>[0]; expect(migrations['>=4.14.0'](before)).toEqual({ telephonyPreferredServer: 'https://chat.example.com', diff --git a/src/deepLinks/main.spec.ts b/src/deepLinks/main.spec.ts index 6fd84bbdd2..b26422934b 100644 --- a/src/deepLinks/main.spec.ts +++ b/src/deepLinks/main.spec.ts @@ -135,6 +135,22 @@ describe('deepLinks/main.ts', () => { }); }); + it('should ignore query strings in callto:// authority format', () => { + const result = parseTelephonyLink('callto://+491234567890?source=crm'); + expect(result).toEqual({ + phoneNumber: '+491234567890', + rawUri: 'callto://+491234567890?source=crm', + }); + }); + + it('should ignore fragments in callto:// authority format', () => { + const result = parseTelephonyLink('callto://+491234567890#details'); + expect(result).toEqual({ + phoneNumber: '+491234567890', + rawUri: 'callto://+491234567890#details', + }); + }); + it('should preserve callto: with extension syntax', () => { const result = parseTelephonyLink('callto:+1234;ext=5678'); expect(result).toEqual({ diff --git a/src/telephony/links.ts b/src/telephony/links.ts index a18c223b6b..7343ba5076 100644 --- a/src/telephony/links.ts +++ b/src/telephony/links.ts @@ -2,6 +2,11 @@ import type { TelephonyLink } from './common'; const TELEPHONY_PROTOCOLS = ['tel:', 'callto:']; +const getTelephonyTarget = (url: URL): string => + url.host || + url.pathname || + url.href.slice(url.protocol.length).split(/[?#]/)[0]; + export const parseTelephonyLink = (input: string): TelephonyLink | null => { if (/^--/.test(input)) { return null; @@ -21,9 +26,7 @@ export const parseTelephonyLink = (input: string): TelephonyLink | null => { let raw: string; try { - raw = decodeURIComponent( - url.pathname || url.href.slice(url.protocol.length) - ); + raw = decodeURIComponent(getTelephonyTarget(url)); } catch { return null; } diff --git a/src/telephony/reducers.ts b/src/telephony/reducers.ts index 407073c31c..b1b606b595 100644 --- a/src/telephony/reducers.ts +++ b/src/telephony/reducers.ts @@ -45,9 +45,7 @@ const normalizeTelephonyGlobalShortcutConfig = ( return defaultTelephonyGlobalShortcutConfig; } - const accelerator = normalizeTelephonyShortcutAccelerator( - config.accelerator - ); + const accelerator = normalizeTelephonyShortcutAccelerator(config.accelerator); return { enabled: config.enabled === true && Boolean(accelerator), diff --git a/src/ui/components/TelephonyServerSelectModal/index.spec.tsx b/src/ui/components/TelephonyServerSelectModal/index.spec.tsx index 5cc50bc44b..6ad7ff570a 100644 --- a/src/ui/components/TelephonyServerSelectModal/index.spec.tsx +++ b/src/ui/components/TelephonyServerSelectModal/index.spec.tsx @@ -207,4 +207,45 @@ describe('TelephonyServerSelectModal', () => { // Suppress unused var warning — dispatchSpy was used to trigger close above expect(dispatchSpy).toHaveBeenCalled(); }); + + it('rememberChoice resets when the dialog is closed by state update', () => { + const store = makeStore(openDialogState()); + + const { rerender } = render( + + + + ); + + fireEvent.click( + screen.getByText('dialog.telephonySelectServer.rememberChoice') + ); + + rerender( + + + + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + const reopenedStore = makeStore(openDialogState()); + const dispatchSpy = jest.spyOn(reopenedStore, 'dispatch'); + + rerender( + + + + ); + + fireEvent.click(screen.getByText('Alpha Chat')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: { + serverUrl: 'https://chat.alpha.com', + rememberChoice: false, + }, + }); + }); }); diff --git a/src/ui/components/TelephonyServerSelectModal/index.tsx b/src/ui/components/TelephonyServerSelectModal/index.tsx index e8b5e98cf1..a126b7149a 100644 --- a/src/ui/components/TelephonyServerSelectModal/index.tsx +++ b/src/ui/components/TelephonyServerSelectModal/index.tsx @@ -1,5 +1,5 @@ import { Box, CheckBox } from '@rocket.chat/fuselage'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import type { Dispatch } from 'redux'; @@ -22,6 +22,12 @@ export const TelephonyServerSelectModal = () => { const [rememberChoice, setRememberChoice] = useState(false); + useEffect(() => { + if (!isVisible) { + setRememberChoice(false); + } + }, [isVisible]); + const handleClose = () => { dispatch({ type: TELEPHONY_SERVER_SELECT_CLOSE, payload: null }); setRememberChoice(false); From c63b43172be79de687174ee9259946ba4758cd20 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Thu, 14 May 2026 12:38:14 -0300 Subject: [PATCH 18/55] chore: format telephony PR lint fixes --- src/app/PersistableValues.spec.ts | 4 ++-- src/telephony/reducers.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/PersistableValues.spec.ts b/src/app/PersistableValues.spec.ts index 5c75347462..b7d3fa4def 100644 --- a/src/app/PersistableValues.spec.ts +++ b/src/app/PersistableValues.spec.ts @@ -2,9 +2,9 @@ import { migrations } from './PersistableValues'; describe('PersistableValues migrations', () => { it('adds telephony shortcut config without losing a persisted telephony server', () => { - const before = ({ + const before = { telephonyPreferredServer: 'https://chat.example.com', - } as unknown) as Parameters<(typeof migrations)['>=4.14.0']>[0]; + } as unknown as Parameters<(typeof migrations)['>=4.14.0']>[0]; expect(migrations['>=4.14.0'](before)).toEqual({ telephonyPreferredServer: 'https://chat.example.com', diff --git a/src/telephony/reducers.ts b/src/telephony/reducers.ts index 407073c31c..b1b606b595 100644 --- a/src/telephony/reducers.ts +++ b/src/telephony/reducers.ts @@ -45,9 +45,7 @@ const normalizeTelephonyGlobalShortcutConfig = ( return defaultTelephonyGlobalShortcutConfig; } - const accelerator = normalizeTelephonyShortcutAccelerator( - config.accelerator - ); + const accelerator = normalizeTelephonyShortcutAccelerator(config.accelerator); return { enabled: config.enabled === true && Boolean(accelerator), From e85a457e2ad446c47581095896f793188343c5fd Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Thu, 14 May 2026 14:35:25 -0300 Subject: [PATCH 19/55] refactor: add marginBlock to Field components in SettingsView features Updated the AvailableBrowsers, TelephonyGlobalShortcut, TelephonyServer, and ThemeAppearance components to include a marginBlock of 'x16' on the Field components for improved spacing and layout consistency. --- src/ui/components/SettingsView/features/AvailableBrowsers.tsx | 2 +- .../SettingsView/features/TelephonyGlobalShortcut.tsx | 2 +- src/ui/components/SettingsView/features/TelephonyServer.tsx | 2 +- src/ui/components/SettingsView/features/ThemeAppearance.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ui/components/SettingsView/features/AvailableBrowsers.tsx b/src/ui/components/SettingsView/features/AvailableBrowsers.tsx index 5cc452db6b..6ea66aef8c 100644 --- a/src/ui/components/SettingsView/features/AvailableBrowsers.tsx +++ b/src/ui/components/SettingsView/features/AvailableBrowsers.tsx @@ -69,7 +69,7 @@ export const AvailableBrowsers = (props: AvailableBrowsersProps) => { ); return ( - + { telephonyGlobalShortcutConfig.accelerator; return ( - + {t('settings.options.telephonyShortcut.title')} diff --git a/src/ui/components/SettingsView/features/TelephonyServer.tsx b/src/ui/components/SettingsView/features/TelephonyServer.tsx index 2cd1585c89..c23b5df6db 100644 --- a/src/ui/components/SettingsView/features/TelephonyServer.tsx +++ b/src/ui/components/SettingsView/features/TelephonyServer.tsx @@ -58,7 +58,7 @@ export const TelephonyServer = () => { } return ( - + { ); return ( - + Date: Thu, 14 May 2026 14:52:26 -0300 Subject: [PATCH 20/55] chore: polish telephony settings copy --- src/i18n/en.i18n.json | 4 ++-- .../SettingsView/features/TelephonyGlobalShortcut.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index d977b888f9..dd67b45094 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -299,12 +299,12 @@ }, "telephonyServer": { "title": "Telephony Server", - "description": "Choose which server handles telephony calls from global shortcuts and tel:/callto: links.", + "description": "Choose which workspace opens when you use the telephony shortcut or a tel: or callto: link.", "auto": "Auto (ask each time)" }, "telephonyShortcut": { "title": "Telephony Global Shortcut", - "description": "Press this shortcut anywhere to focus Rocket.Chat and open the telephony dial pad. Clipboard is read only when the shortcut is pressed.", + "description": "Use this shortcut from anywhere to bring Rocket.Chat to the front and open the telephony dial pad. If your clipboard contains text that looks like a phone number, Rocket.Chat pre-fills it.", "placeholder": "CommandOrControl+Shift+D", "capturePlaceholder": "Press keys...", "save": "Save", diff --git a/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx index cf5a746477..3c6f02753f 100644 --- a/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx +++ b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx @@ -161,7 +161,7 @@ export const TelephonyGlobalShortcut = () => { telephonyGlobalShortcutConfig.accelerator; return ( - + {t('settings.options.telephonyShortcut.title')} From f952c6ff8c46867b02e0ea9a2d009bd02ab2a219 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Thu, 14 May 2026 15:07:04 -0300 Subject: [PATCH 21/55] chore: add telephony settings translations --- src/i18n/ar.i18n.json | 15 +++++++++++++++ src/i18n/de-DE.i18n.json | 15 +++++++++++++++ src/i18n/es.i18n.json | 15 +++++++++++++++ src/i18n/fi.i18n.json | 15 +++++++++++++++ src/i18n/fr.i18n.json | 15 +++++++++++++++ src/i18n/hu.i18n.json | 17 ++++++++++++++++- src/i18n/it-IT.i18n.json | 15 +++++++++++++++ src/i18n/ja.i18n.json | 15 +++++++++++++++ src/i18n/nb-NO.i18n.json | 15 +++++++++++++++ src/i18n/nn.i18n.json | 15 +++++++++++++++ src/i18n/no.i18n.json | 15 +++++++++++++++ src/i18n/pl.i18n.json | 15 +++++++++++++++ src/i18n/pt-BR.i18n.json | 15 +++++++++++++++ src/i18n/ru.i18n.json | 15 +++++++++++++++ src/i18n/se.i18n.json | 15 +++++++++++++++ src/i18n/sv.i18n.json | 15 +++++++++++++++ src/i18n/tr-TR.i18n.json | 15 +++++++++++++++ src/i18n/uk-UA.i18n.json | 15 +++++++++++++++ src/i18n/zh-CN.i18n.json | 15 +++++++++++++++ src/i18n/zh-TW.i18n.json | 15 +++++++++++++++ src/i18n/zh.i18n.json | 15 +++++++++++++++ 21 files changed, 316 insertions(+), 1 deletion(-) diff --git a/src/i18n/ar.i18n.json b/src/i18n/ar.i18n.json index 8cb2489e80..08fe599119 100644 --- a/src/i18n/ar.i18n.json +++ b/src/i18n/ar.i18n.json @@ -18,6 +18,21 @@ "auto": "اتباع النظام", "light": "فاتح", "dark": "داكن" + }, + "telephonyServer": { + "title": "خادم الاتصالات الهاتفية", + "description": "اختر مساحة العمل التي تفتح عند استخدام اختصار الاتصالات الهاتفية أو رابط tel: أو callto:.", + "auto": "تلقائي (السؤال في كل مرة)" + }, + "telephonyShortcut": { + "title": "اختصار الاتصالات الهاتفية العام", + "description": "استخدم هذا الاختصار من أي مكان لإحضار Rocket.Chat إلى المقدمة وفتح لوحة الاتصال الهاتفية. إذا كانت الحافظة تحتوي على نص يبدو كرقم هاتف، فسيقوم Rocket.Chat بتعبئته مسبقًا.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "اضغط على المفاتيح...", + "save": "حفظ", + "clear": "مسح", + "registered": "تم تسجيل الاختصار", + "reservedAccelerator": "{{accelerator}} محجوز بواسطة Rocket.Chat أو نظام التشغيل لديك." } } }, diff --git a/src/i18n/de-DE.i18n.json b/src/i18n/de-DE.i18n.json index ad1c5c179e..7c0e7fe594 100644 --- a/src/i18n/de-DE.i18n.json +++ b/src/i18n/de-DE.i18n.json @@ -258,6 +258,21 @@ "loading": "Browser werden geladen...", "current": "Aktuell verwendet:" }, + "telephonyServer": { + "title": "Telefonie-Server", + "description": "Wählen Sie aus, welcher Workspace geöffnet wird, wenn Sie die Telefonie-Tastenkombination oder einen tel:- bzw. callto:-Link verwenden.", + "auto": "Automatisch (jedes Mal fragen)" + }, + "telephonyShortcut": { + "title": "Globale Telefonie-Tastenkombination", + "description": "Verwenden Sie diese Tastenkombination von überall aus, um Rocket.Chat in den Vordergrund zu bringen und das Telefonie-Wählfeld zu öffnen. Wenn Ihre Zwischenablage Text enthält, der wie eine Telefonnummer aussieht, füllt Rocket.Chat ihn vorab aus.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Tasten drücken...", + "save": "Speichern", + "clear": "Löschen", + "registered": "Tastenkombination registriert", + "reservedAccelerator": "{{accelerator}} ist von Rocket.Chat oder Ihrem Betriebssystem reserviert." + }, "clearPermittedScreenCaptureServers": { "title": "Erlaubte Bildschirmaufnahmeserver löschen", "description": "Löschen Sie Server, die Bildschirme von dieser App erfassen dürfen" diff --git a/src/i18n/es.i18n.json b/src/i18n/es.i18n.json index e8020f900b..57d1f140a8 100644 --- a/src/i18n/es.i18n.json +++ b/src/i18n/es.i18n.json @@ -277,6 +277,21 @@ "loading": "Cargando navegadores...", "current": "Usando actualmente:" }, + "telephonyServer": { + "title": "Servidor de telefonía", + "description": "Elige qué espacio de trabajo se abre cuando usas el atajo de telefonía o un enlace tel: o callto:.", + "auto": "Automático (preguntar siempre)" + }, + "telephonyShortcut": { + "title": "Atajo global de telefonía", + "description": "Usa este atajo desde cualquier lugar para traer Rocket.Chat al frente y abrir el teclado de marcado de telefonía. Si tu portapapeles contiene texto que parece un número de teléfono, Rocket.Chat lo completará automáticamente.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Presiona las teclas...", + "save": "Guardar", + "clear": "Borrar", + "registered": "Atajo registrado", + "reservedAccelerator": "{{accelerator}} está reservado por Rocket.Chat o por tu sistema operativo." + }, "clearPermittedScreenCaptureServers": { "title": "Borrar permisos de captura de pantalla", "description": "Borrar los permisos de captura de pantalla que se seleccionaron para no volver a preguntar en las videollamadas." diff --git a/src/i18n/fi.i18n.json b/src/i18n/fi.i18n.json index 37adf9e499..de987209e0 100644 --- a/src/i18n/fi.i18n.json +++ b/src/i18n/fi.i18n.json @@ -265,6 +265,21 @@ "loading": "Ladataan selaimia...", "current": "Käytössä nyt:" }, + "telephonyServer": { + "title": "Puhelinpalvelin", + "description": "Valitse, mikä työtila avataan, kun käytät puhelutoiminnon pikanäppäintä tai tel:- tai callto:-linkkiä.", + "auto": "Automaattinen (kysy joka kerta)" + }, + "telephonyShortcut": { + "title": "Puhelutoiminnon yleinen pikanäppäin", + "description": "Käytä tätä pikanäppäintä mistä tahansa tuodaksesi Rocket.Chatin etualalle ja avataksesi puhelutoiminnon numeronäppäimistön. Jos leikepöydälläsi on tekstiä, joka näyttää puhelinnumerolta, Rocket.Chat täyttää sen valmiiksi.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Paina näppäimiä...", + "save": "Tallenna", + "clear": "Tyhjennä", + "registered": "Pikanäppäin rekisteröity", + "reservedAccelerator": "{{accelerator}} on Rocket.Chatin tai käyttöjärjestelmäsi varaama." + }, "clearPermittedScreenCaptureServers": { "title": "Tyhjennä näyttökuvaoikeudet", "description": "Tyhjennä valitut näyttökuvaoikeudet, joilla estettiin kysyminen uudelleen videopuheluissa." diff --git a/src/i18n/fr.i18n.json b/src/i18n/fr.i18n.json index bef45644a5..01b8dbcc08 100644 --- a/src/i18n/fr.i18n.json +++ b/src/i18n/fr.i18n.json @@ -265,6 +265,21 @@ "loading": "Chargement des navigateurs...", "current": "Actuellement utilisé:" }, + "telephonyServer": { + "title": "Serveur de téléphonie", + "description": "Choisissez l'espace de travail qui s'ouvre lorsque vous utilisez le raccourci de téléphonie ou un lien tel: ou callto:.", + "auto": "Auto (demander à chaque fois)" + }, + "telephonyShortcut": { + "title": "Raccourci global de téléphonie", + "description": "Utilisez ce raccourci depuis n'importe où pour ramener Rocket.Chat au premier plan et ouvrir le clavier de numérotation. Si votre presse-papiers contient du texte qui ressemble à un numéro de téléphone, Rocket.Chat le préremplit.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Appuyez sur des touches...", + "save": "Enregistrer", + "clear": "Effacer", + "registered": "Raccourci enregistré", + "reservedAccelerator": "{{accelerator}} est réservé par Rocket.Chat ou votre système d'exploitation." + }, "clearPermittedScreenCaptureServers": { "title": "Effacer les autorisations de capture d'écran", "description": "Effacez les autorisations de capture d'écran qui ont été sélectionnées pour ne plus demander lors des appels vidéo." diff --git a/src/i18n/hu.i18n.json b/src/i18n/hu.i18n.json index db530b98b9..b897129d14 100644 --- a/src/i18n/hu.i18n.json +++ b/src/i18n/hu.i18n.json @@ -290,6 +290,21 @@ "loading": "Böngészők betöltése…", "current": "Jelenleg használt:" }, + "telephonyServer": { + "title": "Telefonos kiszolgáló", + "description": "Válaszd ki, melyik munkaterület nyíljon meg, amikor a telefonos gyorsbillentyűt vagy egy tel: vagy callto: hivatkozást használsz.", + "auto": "Automatikus (mindig kérdezzen)" + }, + "telephonyShortcut": { + "title": "Globális telefonos gyorsbillentyű", + "description": "Ezzel a gyorsbillentyűvel bárhonnan előtérbe hozhatod a Rocket.Chatet, és megnyithatod a telefonos tárcsázót. Ha a vágólapodon telefonszámnak tűnő szöveg van, a Rocket.Chat előre kitölti.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Nyomd meg a billentyűket...", + "save": "Mentés", + "clear": "Törlés", + "registered": "Gyorsbillentyű regisztrálva", + "reservedAccelerator": "{{accelerator}} a Rocket.Chat vagy az operációs rendszer által fenntartott." + }, "clearPermittedScreenCaptureServers": { "title": "Képernyőfelvételi engedélyek törlése", "description": "Azon képernyőfelvételi engedélyek törlése, amelyek úgy lettek kiválasztva, hogy ne kérdezzenek újra a videohívásoknál." @@ -631,4 +646,4 @@ "expiresOn": "Lejár ekkor: {{date}}" } } -} \ No newline at end of file +} diff --git a/src/i18n/it-IT.i18n.json b/src/i18n/it-IT.i18n.json index 51c20dea2e..b119df54d3 100644 --- a/src/i18n/it-IT.i18n.json +++ b/src/i18n/it-IT.i18n.json @@ -18,6 +18,21 @@ "auto": "Segui sistema", "light": "Chiaro", "dark": "Scuro" + }, + "telephonyServer": { + "title": "Server telefonia", + "description": "Scegli quale spazio di lavoro si apre quando usi la scorciatoia di telefonia o un link tel: o callto:.", + "auto": "Automatico (chiedi ogni volta)" + }, + "telephonyShortcut": { + "title": "Scorciatoia globale di telefonia", + "description": "Usa questa scorciatoia da qualsiasi punto per portare Rocket.Chat in primo piano e aprire il tastierino di telefonia. Se gli appunti contengono testo che sembra un numero di telefono, Rocket.Chat lo precompila.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Premi i tasti...", + "save": "Salva", + "clear": "Cancella", + "registered": "Scorciatoia registrata", + "reservedAccelerator": "{{accelerator}} è riservata da Rocket.Chat o dal tuo sistema operativo." } } }, diff --git a/src/i18n/ja.i18n.json b/src/i18n/ja.i18n.json index 06dc8ec815..613b81aa60 100644 --- a/src/i18n/ja.i18n.json +++ b/src/i18n/ja.i18n.json @@ -127,6 +127,21 @@ "auto": "システムに従う", "light": "ライト", "dark": "ダーク" + }, + "telephonyServer": { + "title": "テレフォニーサーバー", + "description": "テレフォニーショートカットまたは tel: / callto: リンクを使用したときに開くワークスペースを選択します。", + "auto": "自動(毎回確認)" + }, + "telephonyShortcut": { + "title": "テレフォニーのグローバルショートカット", + "description": "このショートカットをどこからでも使用して Rocket.Chat を前面に表示し、テレフォニーのダイヤルパッドを開きます。クリップボードに電話番号のようなテキストがある場合、Rocket.Chat が自動的に入力します。", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "キーを押してください...", + "save": "保存", + "clear": "クリア", + "registered": "ショートカットを登録しました", + "reservedAccelerator": "{{accelerator}} は Rocket.Chat またはオペレーティングシステムによって予約されています。" } } }, diff --git a/src/i18n/nb-NO.i18n.json b/src/i18n/nb-NO.i18n.json index f6d4e62bdb..99f7b3d759 100644 --- a/src/i18n/nb-NO.i18n.json +++ b/src/i18n/nb-NO.i18n.json @@ -18,6 +18,21 @@ "auto": "Følg system", "light": "Lys", "dark": "Mørk" + }, + "telephonyServer": { + "title": "Telefoniserver", + "description": "Velg hvilket arbeidsområde som åpnes når du bruker telefonisnarveien eller en tel:- eller callto:-lenke.", + "auto": "Automatisk (spør hver gang)" + }, + "telephonyShortcut": { + "title": "Global telefonisnarvei", + "description": "Bruk denne snarveien hvor som helst for å hente Rocket.Chat frem og åpne telefonitastaturet. Hvis utklippstavlen inneholder tekst som ser ut som et telefonnummer, fyller Rocket.Chat det ut på forhånd.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Trykk på taster...", + "save": "Lagre", + "clear": "Tøm", + "registered": "Snarvei registrert", + "reservedAccelerator": "{{accelerator}} er reservert av Rocket.Chat eller operativsystemet ditt." } } }, diff --git a/src/i18n/nn.i18n.json b/src/i18n/nn.i18n.json index ac6f1375cf..f8b3a27d70 100644 --- a/src/i18n/nn.i18n.json +++ b/src/i18n/nn.i18n.json @@ -18,6 +18,21 @@ "auto": "Følg system", "light": "Lys", "dark": "Mørk" + }, + "telephonyServer": { + "title": "Telefonitenar", + "description": "Vel kva arbeidsområde som opnast når du brukar telefonisnarvegen eller ei tel:- eller callto:-lenkje.", + "auto": "Automatisk (spør kvar gong)" + }, + "telephonyShortcut": { + "title": "Global telefonisnarveg", + "description": "Bruk denne snarvegen kvar som helst for å hente Rocket.Chat fram og opne telefonitastaturet. Dersom utklippstavla inneheld tekst som ser ut som eit telefonnummer, fyller Rocket.Chat det ut på førehand.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Trykk på tastar...", + "save": "Lagre", + "clear": "Tøm", + "registered": "Snarveg registrert", + "reservedAccelerator": "{{accelerator}} er reservert av Rocket.Chat eller operativsystemet ditt." } } }, diff --git a/src/i18n/no.i18n.json b/src/i18n/no.i18n.json index e82c203365..7757157638 100644 --- a/src/i18n/no.i18n.json +++ b/src/i18n/no.i18n.json @@ -297,6 +297,21 @@ "loading": "Laster inn nettlesere ...", "current": "Bruker for øyeblikket:" }, + "telephonyServer": { + "title": "Telefoniserver", + "description": "Velg hvilket arbeidsområde som åpnes når du bruker telefonisnarveien eller en tel:- eller callto:-lenke.", + "auto": "Automatisk (spør hver gang)" + }, + "telephonyShortcut": { + "title": "Global telefonisnarvei", + "description": "Bruk denne snarveien hvor som helst for å hente Rocket.Chat frem og åpne telefonitastaturet. Hvis utklippstavlen inneholder tekst som ser ut som et telefonnummer, fyller Rocket.Chat det ut på forhånd.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Trykk på taster...", + "save": "Lagre", + "clear": "Tøm", + "registered": "Snarvei registrert", + "reservedAccelerator": "{{accelerator}} er reservert av Rocket.Chat eller operativsystemet ditt." + }, "clearPermittedScreenCaptureServers": { "title": "Fjern skjermopptakstillatelser", "description": "Fjern skjermopptakstillatelsene på videosamtaler som ble valgt for ikke å spørre igjen." diff --git a/src/i18n/pl.i18n.json b/src/i18n/pl.i18n.json index a818435412..a85eaea0d7 100644 --- a/src/i18n/pl.i18n.json +++ b/src/i18n/pl.i18n.json @@ -142,6 +142,21 @@ "loading": "Ładowanie przeglądarek...", "current": "Aktualnie używana:" }, + "telephonyServer": { + "title": "Serwer telefonii", + "description": "Wybierz, który obszar roboczy otwiera się po użyciu skrótu telefonii albo linku tel: lub callto:.", + "auto": "Automatycznie (pytaj za każdym razem)" + }, + "telephonyShortcut": { + "title": "Globalny skrót telefonii", + "description": "Użyj tego skrótu z dowolnego miejsca, aby przenieść Rocket.Chat na pierwszy plan i otworzyć klawiaturę wybierania. Jeśli schowek zawiera tekst wyglądający jak numer telefonu, Rocket.Chat wypełni go automatycznie.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Naciśnij klawisze...", + "save": "Zapisz", + "clear": "Wyczyść", + "registered": "Skrót zarejestrowany", + "reservedAccelerator": "{{accelerator}} jest zarezerwowany przez Rocket.Chat lub system operacyjny." + }, "transparentWindow": { "title": "Efekt przezroczystego okna", "description": "Włącz natywny efekt wibracji/przezroczystości dla okna. Wymaga ponownego uruchomienia, aby zastosować." diff --git a/src/i18n/pt-BR.i18n.json b/src/i18n/pt-BR.i18n.json index c4aec5499b..ee08a65c60 100644 --- a/src/i18n/pt-BR.i18n.json +++ b/src/i18n/pt-BR.i18n.json @@ -272,6 +272,21 @@ "loading": "Carregando navegadores...", "current": "Usando atualmente:" }, + "telephonyServer": { + "title": "Servidor de Telefonia", + "description": "Escolha qual espaço de trabalho será aberto quando você usar o atalho de telefonia ou um link tel: ou callto:.", + "auto": "Automático (perguntar sempre)" + }, + "telephonyShortcut": { + "title": "Atalho Global de Telefonia", + "description": "Use este atalho de qualquer lugar para trazer o Rocket.Chat para frente e abrir o teclado de telefonia. Se a área de transferência contiver um texto que pareça um número de telefone, o Rocket.Chat o preencherá automaticamente.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Pressione as teclas...", + "save": "Salvar", + "clear": "Limpar", + "registered": "Atalho registrado", + "reservedAccelerator": "{{accelerator}} está reservado pelo Rocket.Chat ou pelo seu sistema operacional." + }, "clearPermittedScreenCaptureServers": { "title": "Limpar Permissões de Captura de Tela", "description": "Limpar as permissões de captura de tela que foram selecionadas para não perguntar novamente em chamadas de vídeo." diff --git a/src/i18n/ru.i18n.json b/src/i18n/ru.i18n.json index e8e860f857..e88b86cd4d 100644 --- a/src/i18n/ru.i18n.json +++ b/src/i18n/ru.i18n.json @@ -259,6 +259,21 @@ "loading": "Загрузка браузеров...", "current": "Сейчас используется:" }, + "telephonyServer": { + "title": "Сервер телефонии", + "description": "Выберите рабочее пространство, которое будет открываться при использовании сочетания клавиш телефонии или ссылки tel: либо callto:.", + "auto": "Авто (спрашивать каждый раз)" + }, + "telephonyShortcut": { + "title": "Глобальное сочетание клавиш телефонии", + "description": "Используйте это сочетание клавиш из любого места, чтобы вывести Rocket.Chat на передний план и открыть панель набора номера. Если в буфере обмена есть текст, похожий на номер телефона, Rocket.Chat подставит его автоматически.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Нажмите клавиши...", + "save": "Сохранить", + "clear": "Очистить", + "registered": "Сочетание клавиш зарегистрировано", + "reservedAccelerator": "{{accelerator}} зарезервировано Rocket.Chat или вашей операционной системой." + }, "clearPermittedScreenCaptureServers": { "title": "Очистить разрешенные серверы захвата экрана", "description": "Выберите серверы, которые могут захватывать экраны приложений из этого приложения." diff --git a/src/i18n/se.i18n.json b/src/i18n/se.i18n.json index 926f040115..27cb1485c5 100644 --- a/src/i18n/se.i18n.json +++ b/src/i18n/se.i18n.json @@ -18,6 +18,21 @@ "auto": "Följ system", "light": "Ljus", "dark": "Mörk" + }, + "telephonyServer": { + "title": "Telefoniabálvá", + "description": "Vállje guđe bargosadji rahpasa go geavahat telefoniija oanehisboalu dahje tel: dahje callto: liŋkka.", + "auto": "Automáhtalaš (jeara juohke háve)" + }, + "telephonyShortcut": { + "title": "Telefoniija globála oanehisboallu", + "description": "Geavat dán oanehisboalu gos fal vai Rocket.Chat boahtá ovdii ja telefoniija numerboallu rahpasa. Jus čuohpusis lea teaksta mii orru telefonnummirin, de Rocket.Chat deavdá dan ovdagihtii.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Deaddil boaluid...", + "save": "Vurke", + "clear": "Sálke", + "registered": "Oanehisboallu lea registrerejuvvon", + "reservedAccelerator": "{{accelerator}} lea Rocket.Chat dahje du operatiivavuogádaga várrejuvvon." } } }, diff --git a/src/i18n/sv.i18n.json b/src/i18n/sv.i18n.json index f1854592fd..91c65c3fc9 100644 --- a/src/i18n/sv.i18n.json +++ b/src/i18n/sv.i18n.json @@ -286,6 +286,21 @@ "loading": "Laddar webbläsare...", "current": "Använder för närvarande:" }, + "telephonyServer": { + "title": "Telefoniserver", + "description": "Välj vilken arbetsyta som öppnas när du använder telefonigenvägen eller en tel:- eller callto:-länk.", + "auto": "Automatiskt (fråga varje gång)" + }, + "telephonyShortcut": { + "title": "Global telefonigenväg", + "description": "Använd den här genvägen var som helst för att ta Rocket.Chat till förgrunden och öppna telefonins knappsats. Om urklippet innehåller text som ser ut som ett telefonnummer fyller Rocket.Chat i den i förväg.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Tryck på tangenter...", + "save": "Spara", + "clear": "Rensa", + "registered": "Genväg registrerad", + "reservedAccelerator": "{{accelerator}} är reserverad av Rocket.Chat eller ditt operativsystem." + }, "clearPermittedScreenCaptureServers": { "title": "Rensa behörigheter för skärmdumpning", "description": "Ta bort skärmdumpstillstånden som valts för att inte fråga igen vid videosamtal." diff --git a/src/i18n/tr-TR.i18n.json b/src/i18n/tr-TR.i18n.json index 599563c388..766042f212 100644 --- a/src/i18n/tr-TR.i18n.json +++ b/src/i18n/tr-TR.i18n.json @@ -130,6 +130,21 @@ "loading": "Tarayıcılar yükleniyor...", "current": "Şu anda kullanılan:" }, + "telephonyServer": { + "title": "Telefon sunucusu", + "description": "Telefon kısayolunu veya tel: ya da callto: bağlantısını kullandığınızda hangi çalışma alanının açılacağını seçin.", + "auto": "Otomatik (her seferinde sor)" + }, + "telephonyShortcut": { + "title": "Genel telefon kısayolu", + "description": "Rocket.Chat'i öne getirmek ve telefon arama tuş takımını açmak için bu kısayolu her yerden kullanın. Panonuzda telefon numarasına benzeyen bir metin varsa Rocket.Chat bunu otomatik olarak doldurur.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Tuşlara basın...", + "save": "Kaydet", + "clear": "Temizle", + "registered": "Kısayol kaydedildi", + "reservedAccelerator": "{{accelerator}}, Rocket.Chat veya işletim sisteminiz tarafından ayrılmıştır." + }, "transparentWindow": { "title": "Şeffaf pencere efekti", "description": "Pencere için yerel titreşim/şeffaflık efektini etkinleştir. Uygulamak için yeniden başlatma gerektirir." diff --git a/src/i18n/uk-UA.i18n.json b/src/i18n/uk-UA.i18n.json index 24f998a4d1..9d1b63da77 100644 --- a/src/i18n/uk-UA.i18n.json +++ b/src/i18n/uk-UA.i18n.json @@ -107,6 +107,21 @@ "loading": "Завантаження браузерів...", "current": "Зараз використовується:" }, + "telephonyServer": { + "title": "Сервер телефонії", + "description": "Виберіть робочий простір, який відкриватиметься під час використання ярлика телефонії або посилання tel: чи callto:.", + "auto": "Автоматично (запитувати щоразу)" + }, + "telephonyShortcut": { + "title": "Глобальний ярлик телефонії", + "description": "Використовуйте цей ярлик звідусіль, щоб вивести Rocket.Chat на передній план і відкрити панель набору номера. Якщо буфер обміну містить текст, схожий на номер телефону, Rocket.Chat підставить його автоматично.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Натисніть клавіші...", + "save": "Зберегти", + "clear": "Очистити", + "registered": "Ярлик зареєстровано", + "reservedAccelerator": "{{accelerator}} зарезервовано Rocket.Chat або вашою операційною системою." + }, "transparentWindow": { "title": "Ефект прозорого вікна", "description": "Увімкнути нативний ефект вібрації/прозорості для вікна. Потрібен перезапуск для застосування." diff --git a/src/i18n/zh-CN.i18n.json b/src/i18n/zh-CN.i18n.json index 15cc51e9ab..53cce81773 100644 --- a/src/i18n/zh-CN.i18n.json +++ b/src/i18n/zh-CN.i18n.json @@ -177,6 +177,21 @@ "auto": "跟随系统", "light": "浅色", "dark": "深色" + }, + "telephonyServer": { + "title": "语音通话服务器", + "description": "选择使用语音通话快捷键或 tel:、callto: 链接时要打开的工作区。", + "auto": "自动(每次询问)" + }, + "telephonyShortcut": { + "title": "语音通话全局快捷键", + "description": "在任何地方使用此快捷键,将 Rocket.Chat 置于前台并打开语音通话拨号盘。如果剪贴板中包含看起来像电话号码的文本,Rocket.Chat 会自动预填。", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "按下按键...", + "save": "保存", + "clear": "清除", + "registered": "快捷键已注册", + "reservedAccelerator": "{{accelerator}} 已被 Rocket.Chat 或您的操作系统保留。" } } }, diff --git a/src/i18n/zh-TW.i18n.json b/src/i18n/zh-TW.i18n.json index f33c041e14..79962665f1 100644 --- a/src/i18n/zh-TW.i18n.json +++ b/src/i18n/zh-TW.i18n.json @@ -109,6 +109,21 @@ "loading": "正在載入瀏覽器...", "current": "目前使用:" }, + "telephonyServer": { + "title": "語音通話伺服器", + "description": "選擇使用語音通話快捷鍵或 tel:、callto: 連結時要開啟的工作區。", + "auto": "自動(每次詢問)" + }, + "telephonyShortcut": { + "title": "語音通話全域快捷鍵", + "description": "在任何地方使用此快捷鍵,將 Rocket.Chat 帶到最前方並開啟語音通話撥號盤。如果剪貼簿包含看起來像電話號碼的文字,Rocket.Chat 會自動預先填入。", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "按下按鍵...", + "save": "儲存", + "clear": "清除", + "registered": "快捷鍵已註冊", + "reservedAccelerator": "{{accelerator}} 已由 Rocket.Chat 或您的作業系統保留。" + }, "transparentWindow": { "title": "透明視窗效果", "description": "啟用視窗的原生模糊/透明效果。需要重新啟動才能套用。" diff --git a/src/i18n/zh.i18n.json b/src/i18n/zh.i18n.json index dc1cfb6f3e..2b030d31de 100644 --- a/src/i18n/zh.i18n.json +++ b/src/i18n/zh.i18n.json @@ -18,6 +18,21 @@ "auto": "跟随系统", "light": "浅色", "dark": "深色" + }, + "telephonyServer": { + "title": "语音通话服务器", + "description": "选择使用语音通话快捷键或 tel:、callto: 链接时要打开的工作区。", + "auto": "自动(每次询问)" + }, + "telephonyShortcut": { + "title": "语音通话全局快捷键", + "description": "在任何地方使用此快捷键,将 Rocket.Chat 置于前台并打开语音通话拨号盘。如果剪贴板中包含看起来像电话号码的文本,Rocket.Chat 会自动预填。", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "按下按键...", + "save": "保存", + "clear": "清除", + "registered": "快捷键已注册", + "reservedAccelerator": "{{accelerator}} 已被 Rocket.Chat 或您的操作系统保留。" } } }, From 773cd3051fc0aee9bffc9cfeda9ee7a3e677971a Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Fri, 15 May 2026 11:54:15 -0300 Subject: [PATCH 22/55] feat(telephony): add master toggle and gate runtime registration Add `isTelephonyEnabled` setting (default off) to gate the telephony feature end-to-end: - New persisted `isTelephonyEnabled` boolean with action, reducer, and selector entry; surfaces as a master toggle in Settings > General. - `TelephonyServer` and `TelephonyGlobalShortcut` controls remain visible but disabled while the master toggle is off. - Global shortcut config selector returns the disabled config when the master toggle is off, so the existing watcher auto-unregisters any active accelerator on toggle-off. - `tel:`/`callto:` deep links short-circuit when the master toggle is off. - OS-level protocol registration for `tel`/`callto` moves out of the unconditional startup loop into a new reactive `setupTelephonyProtocolHandlers`, which calls `setAsDefaultProtocolClient` / `removeAsDefaultProtocolClient` in response to toggle changes. `rocketchat:` continues to register at startup unchanged. The macOS `Info.plist` and Linux `.desktop` files declared by electron-builder will still list the app as a candidate handler for `tel`/`callto`, but it will never be set as default unless the user opts in at runtime. --- src/app/PersistableValues.ts | 3 + src/app/main/app.main.spec.ts | 20 +++ src/app/main/app.ts | 5 + src/app/selectors.ts | 1 + src/deepLinks/main.spec.ts | 120 ++++++++++++++++ src/deepLinks/main.ts | 6 + src/i18n/en.i18n.json | 4 + src/main.ts | 6 +- src/store/rootReducer.ts | 2 + src/telephony/main.spec.ts | 129 ++++++++++++++++++ src/telephony/main.ts | 52 ++++++- src/ui/actions.ts | 3 + src/ui/components/SettingsView/GeneralTab.tsx | 2 + .../SettingsView/features/Telephony.spec.tsx | 61 +++++++++ .../SettingsView/features/Telephony.tsx | 58 ++++++++ .../features/TelephonyGlobalShortcut.spec.tsx | 6 +- .../features/TelephonyGlobalShortcut.tsx | 6 + .../SettingsView/features/TelephonyServer.tsx | 4 + src/ui/reducers/isTelephonyEnabled.ts | 27 ++++ 19 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 src/ui/components/SettingsView/features/Telephony.spec.tsx create mode 100644 src/ui/components/SettingsView/features/Telephony.tsx create mode 100644 src/ui/reducers/isTelephonyEnabled.ts diff --git a/src/app/PersistableValues.ts b/src/app/PersistableValues.ts index 4d97ac0ee3..3e6e5f65f8 100644 --- a/src/app/PersistableValues.ts +++ b/src/app/PersistableValues.ts @@ -108,6 +108,7 @@ type PersistableValues_4_13_0 = PersistableValues_4_11_0 & { }; type PersistableValues_4_14_0 = PersistableValues_4_13_0 & { + isTelephonyEnabled: boolean; telephonyPreferredServer: string | null; telephonyGlobalShortcutConfig: TelephonyGlobalShortcutConfig; }; @@ -209,6 +210,8 @@ export const migrations = { }), '>=4.14.0': (before: PersistableValues_4_13_0): PersistableValues_4_14_0 => ({ ...before, + isTelephonyEnabled: + (before as Partial).isTelephonyEnabled ?? false, telephonyPreferredServer: (before as Partial).telephonyPreferredServer ?? null, diff --git a/src/app/main/app.main.spec.ts b/src/app/main/app.main.spec.ts index 8fffea9a6f..1fb625c221 100644 --- a/src/app/main/app.main.spec.ts +++ b/src/app/main/app.main.spec.ts @@ -470,6 +470,26 @@ describe('performElectronStartup - Platform Detection', () => { }); }); + describe('Telephony scheme gating', () => { + it('registers rocketchat at startup', () => { + performElectronStartup(); + + expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith('rocketchat'); + }); + + it('does NOT register tel at startup', () => { + performElectronStartup(); + + expect(app.setAsDefaultProtocolClient).not.toHaveBeenCalledWith('tel'); + }); + + it('does NOT register callto at startup', () => { + performElectronStartup(); + + expect(app.setAsDefaultProtocolClient).not.toHaveBeenCalledWith('callto'); + }); + }); + describe('Integration', () => { it('should work correctly with PipeWire feature enabled', () => { process.env.XDG_SESSION_TYPE = 'x11'; diff --git a/src/app/main/app.ts b/src/app/main/app.ts index fce653bb9f..a5c2eaeab6 100644 --- a/src/app/main/app.ts +++ b/src/app/main/app.ts @@ -47,6 +47,8 @@ export const electronBuilderJsonInformation = { ).flatMap((p) => p.schemes), }; +export const TELEPHONY_SCHEMES = ['tel', 'callto'] as const; + let isScreenCaptureFallbackForced = false; export const getPlatformName = (): string => { @@ -88,6 +90,9 @@ export const relaunchApp = (...args: string[]): void => { export const performElectronStartup = (): void => { for (const scheme of electronBuilderJsonInformation.protocols) { + if ((TELEPHONY_SCHEMES as readonly string[]).includes(scheme)) { + continue; + } app.setAsDefaultProtocolClient(scheme); } app.setAppUserModelId(electronBuilderJsonInformation.appId); diff --git a/src/app/selectors.ts b/src/app/selectors.ts index e81dc6255d..6b73be7d6c 100644 --- a/src/app/selectors.ts +++ b/src/app/selectors.ts @@ -88,4 +88,5 @@ export const selectPersistableValues = createStructuredSelector({ telephonyGlobalShortcutConfig: ({ telephonyGlobalShortcutConfig, }: RootState) => telephonyGlobalShortcutConfig, + isTelephonyEnabled: ({ isTelephonyEnabled }: RootState) => isTelephonyEnabled, }); diff --git a/src/deepLinks/main.spec.ts b/src/deepLinks/main.spec.ts index b26422934b..6dc4a950f0 100644 --- a/src/deepLinks/main.spec.ts +++ b/src/deepLinks/main.spec.ts @@ -540,6 +540,126 @@ describe('deepLinks/main.ts', () => { expect(listenMock).not.toHaveBeenCalled(); }); }); + + describe('isTelephonyEnabled gate for tel: deep links', () => { + const mockBrowserWindow = { + isVisible: jest.fn(() => true), + focus: jest.fn(), + showInactive: jest.fn(), + }; + + const mockWebContents = { + send: jest.fn(), + loadURL: jest.fn(), + }; + + beforeEach(() => { + getRootWindowMock.mockResolvedValue(mockBrowserWindow as any); + getWebContentsByServerUrlMock.mockReturnValue(mockWebContents as any); + }); + + it('does NOT open dialpad for tel: link when isTelephonyEnabled=false', async () => { + setupDeepLinks(); + + selectMock.mockImplementation((selector: any) => + selector({ + isTelephonyEnabled: false, + servers: [{ url: 'https://chat.example.com', title: 'Chat' }], + }) + ); + + const savedArgv = process.argv; + process.argv = ['electron', '.', 'tel:+491234567890']; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + expect(getWebContentsByServerUrlMock).not.toHaveBeenCalled(); + expect(mockWebContents.send).not.toHaveBeenCalled(); + expect(resolveServerUrlMock).not.toHaveBeenCalled(); + }); + + it('does NOT open dialpad for callto: link when isTelephonyEnabled=false', async () => { + setupDeepLinks(); + + selectMock.mockImplementation((selector: any) => + selector({ + isTelephonyEnabled: false, + servers: [{ url: 'https://chat.example.com', title: 'Chat' }], + }) + ); + + const savedArgv = process.argv; + process.argv = ['electron', '.', 'callto:+491234567890']; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + expect(getWebContentsByServerUrlMock).not.toHaveBeenCalled(); + expect(mockWebContents.send).not.toHaveBeenCalled(); + }); + + it('opens dialpad for tel: link when isTelephonyEnabled=true', async () => { + setupDeepLinks(); + + selectMock.mockImplementation((selector: any) => + selector({ + isTelephonyEnabled: true, + servers: [{ url: 'https://chat.example.com', title: 'Chat' }], + }) + ); + + const savedArgv = process.argv; + process.argv = ['electron', '.', 'tel:+491234567890']; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://chat.example.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + } + ); + }); + + it('does not gate non-telephony deep links on isTelephonyEnabled', async () => { + setupDeepLinks(); + + resolveServerUrlMock.mockResolvedValue([ + 'https://chat.example.com', + ServerUrlResolutionStatus.OK, + undefined, + ] as any); + + selectMock.mockImplementation((selector: any) => + selector({ + isTelephonyEnabled: false, + servers: [{ url: 'https://chat.example.com', title: 'Chat' }], + }) + ); + + const savedArgv = process.argv; + process.argv = [ + 'electron', + '.', + 'rocketchat://auth?host=https://chat.example.com&token=abc&userId=123', + ]; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + expect(resolveServerUrlMock).toHaveBeenCalled(); + }); + }); }); describe('telephonyPreferredServer reducer', () => { diff --git a/src/deepLinks/main.ts b/src/deepLinks/main.ts index bae9f2d0ea..a002f67cf5 100644 --- a/src/deepLinks/main.ts +++ b/src/deepLinks/main.ts @@ -205,6 +205,12 @@ const performConference = async ({ host, path }: InviteParams): Promise => const processDeepLink = async (deepLink: string): Promise => { const telephonyLink = parseTelephonyLink(deepLink); if (telephonyLink) { + const isTelephonyEnabled = select( + ({ isTelephonyEnabled }) => isTelephonyEnabled + ); + if (!isTelephonyEnabled) { + return; + } await openTelephonyDialpad(telephonyLink); return; } diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index dd67b45094..90b502e5fb 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -297,6 +297,10 @@ "loading": "Loading browsers...", "current": "Currently using:" }, + "telephony": { + "title": "Telephony", + "description": "Enable the telephony feature. When off, the global shortcut and tel: deep links are disabled and the related settings below are unavailable." + }, "telephonyServer": { "title": "Telephony Server", "description": "Choose which workspace opens when you use the telephony shortcut or a tel: or callto: link.", diff --git a/src/main.ts b/src/main.ts index db551c3c22..c39047edf2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,7 +44,10 @@ import { checkSupportedVersionServers } from './servers/supportedVersions/main'; import { setupSpellChecking } from './spellChecking/main'; import { createMainReduxStore } from './store'; import { applySystemCertificates } from './systemCertificates'; -import { setupTelephonyGlobalShortcut } from './telephony/main'; +import { + setupTelephonyGlobalShortcut, + setupTelephonyProtocolHandlers, +} from './telephony/main'; import { handleCertificatesManager } from './ui/components/CertificatesManager/main'; import dock from './ui/main/dock'; import menuBar from './ui/main/menuBar'; @@ -122,6 +125,7 @@ const start = async (): Promise => { setupDeepLinks(); setupTelephonyGlobalShortcut(); + setupTelephonyProtocolHandlers(); await setupNavigation(); setupPowerMonitor(); await setupUpdates(); diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index 8d86ee4265..323010043e 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -41,6 +41,7 @@ import { isNTLMCredentialsEnabled } from '../ui/reducers/isNTLMCredentialsEnable import { isReportEnabled } from '../ui/reducers/isReportEnabled'; import { isShowWindowOnUnreadChangedEnabled } from '../ui/reducers/isShowWindowOnUnreadChangedEnabled'; import { isSideBarEnabled } from '../ui/reducers/isSideBarEnabled'; +import { isTelephonyEnabled } from '../ui/reducers/isTelephonyEnabled'; import { isTransparentWindowEnabled } from '../ui/reducers/isTransparentWindowEnabled'; import { isTrayIconEnabled } from '../ui/reducers/isTrayIconEnabled'; import { isVerboseOutlookLoggingEnabled } from '../ui/reducers/isVerboseOutlookLoggingEnabled'; @@ -126,6 +127,7 @@ export const rootReducer = combineReducers({ telephonyPreferredServer, telephonyGlobalShortcutConfig, telephonyGlobalShortcutRegistrationStatus, + isTelephonyEnabled, }); export type RootState = ReturnType; diff --git a/src/telephony/main.spec.ts b/src/telephony/main.spec.ts index cc06fdb6cf..59f93e3b25 100644 --- a/src/telephony/main.spec.ts +++ b/src/telephony/main.spec.ts @@ -14,7 +14,9 @@ import { createTelephonyLinkFromClipboardText, registerTelephonyGlobalShortcut, setupTelephonyGlobalShortcut, + setupTelephonyProtocolHandlers, teardownTelephonyGlobalShortcut, + teardownTelephonyProtocolHandlers, triggerTelephonyGlobalShortcut, } from './main'; import { @@ -35,6 +37,8 @@ jest.mock('electron', () => { app: { addListener: jest.fn(), removeListener: jest.fn(), + setAsDefaultProtocolClient: jest.fn(() => true), + removeAsDefaultProtocolClient: jest.fn(() => true), }, clipboard: { readText: jest.fn(), @@ -522,3 +526,128 @@ describe('telephony shortcut reducers', () => { }); }); }); + +describe('telephony protocol handlers gate', () => { + beforeEach(() => { + teardownTelephonyProtocolHandlers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + teardownTelephonyProtocolHandlers(); + }); + + it('registers tel and callto when isTelephonyEnabled becomes true', () => { + watchMock.mockReturnValue(() => undefined); + + setupTelephonyProtocolHandlers(); + const watchCallback = watchMock.mock.calls[0][1] as ( + enabled: boolean + ) => void; + expect(watchCallback).toBeInstanceOf(Function); + + watchCallback(true); + + expect(appMock.setAsDefaultProtocolClient).toHaveBeenCalledWith('tel'); + expect(appMock.setAsDefaultProtocolClient).toHaveBeenCalledWith('callto'); + expect(appMock.removeAsDefaultProtocolClient).not.toHaveBeenCalled(); + }); + + it('unregisters tel and callto when isTelephonyEnabled becomes false', () => { + watchMock.mockReturnValue(() => undefined); + + setupTelephonyProtocolHandlers(); + const watchCallback = watchMock.mock.calls[0][1] as ( + enabled: boolean + ) => void; + + watchCallback(false); + + expect(appMock.removeAsDefaultProtocolClient).toHaveBeenCalledWith('tel'); + expect(appMock.removeAsDefaultProtocolClient).toHaveBeenCalledWith( + 'callto' + ); + expect(appMock.setAsDefaultProtocolClient).not.toHaveBeenCalled(); + }); + + it('is idempotent — repeated setup calls only subscribe once', () => { + const unsubscribe = jest.fn(); + watchMock.mockReturnValue(unsubscribe); + + setupTelephonyProtocolHandlers(); + setupTelephonyProtocolHandlers(); + + expect(watchMock).toHaveBeenCalledTimes(1); + }); + + it('teardown unsubscribes the watcher and detaches will-quit listener', () => { + const unsubscribe = jest.fn(); + watchMock.mockReturnValue(unsubscribe); + + setupTelephonyProtocolHandlers(); + teardownTelephonyProtocolHandlers(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(appMock.removeListener).toHaveBeenCalledWith( + 'will-quit', + teardownTelephonyProtocolHandlers + ); + }); + + it('registers will-quit teardown listener on setup', () => { + watchMock.mockReturnValue(() => undefined); + + setupTelephonyProtocolHandlers(); + + expect(appMock.addListener).toHaveBeenCalledWith( + 'will-quit', + teardownTelephonyProtocolHandlers + ); + }); + + it('continues to second scheme when first scheme registration throws', () => { + watchMock.mockReturnValue(() => undefined); + appMock.setAsDefaultProtocolClient.mockImplementationOnce(() => { + throw new Error('registry locked'); + }); + + setupTelephonyProtocolHandlers(); + const watchCallback = watchMock.mock.calls[0][1] as ( + enabled: boolean + ) => void; + + expect(() => watchCallback(true)).not.toThrow(); + expect(appMock.setAsDefaultProtocolClient).toHaveBeenCalledTimes(2); + expect(appMock.setAsDefaultProtocolClient).toHaveBeenNthCalledWith( + 1, + 'tel' + ); + expect(appMock.setAsDefaultProtocolClient).toHaveBeenNthCalledWith( + 2, + 'callto' + ); + }); + + it('continues to second scheme when first scheme unregistration throws', () => { + watchMock.mockReturnValue(() => undefined); + appMock.removeAsDefaultProtocolClient.mockImplementationOnce(() => { + throw new Error('not registered'); + }); + + setupTelephonyProtocolHandlers(); + const watchCallback = watchMock.mock.calls[0][1] as ( + enabled: boolean + ) => void; + + expect(() => watchCallback(false)).not.toThrow(); + expect(appMock.removeAsDefaultProtocolClient).toHaveBeenCalledTimes(2); + expect(appMock.removeAsDefaultProtocolClient).toHaveBeenNthCalledWith( + 1, + 'tel' + ); + expect(appMock.removeAsDefaultProtocolClient).toHaveBeenNthCalledWith( + 2, + 'callto' + ); + }); +}); diff --git a/src/telephony/main.ts b/src/telephony/main.ts index 1f1e1a9fb3..80e6c83607 100644 --- a/src/telephony/main.ts +++ b/src/telephony/main.ts @@ -1,5 +1,6 @@ import { app, clipboard, globalShortcut, Notification } from 'electron'; +import { TELEPHONY_SCHEMES } from '../app/main/app'; import { logger } from '../logging'; import { dispatch, watch } from '../store'; import type { RootState } from '../store/rootReducer'; @@ -17,10 +18,16 @@ import { const selectTelephonyGlobalShortcutConfig = ({ telephonyGlobalShortcutConfig, -}: RootState): TelephonyGlobalShortcutConfig => telephonyGlobalShortcutConfig; + isTelephonyEnabled, +}: RootState): TelephonyGlobalShortcutConfig => + isTelephonyEnabled ? telephonyGlobalShortcutConfig : DISABLED_SHORTCUT_CONFIG; + +const selectIsTelephonyEnabled = ({ isTelephonyEnabled }: RootState): boolean => + isTelephonyEnabled; let registeredAccelerator: string | null = null; let unsubscribeFromShortcutConfig: (() => void) | null = null; +let unsubscribeFromTelephonyEnabled: (() => void) | null = null; let lastTelephonyShortcutTriggeredAt = 0; const TELEPHONY_GLOBAL_SHORTCUT_DEBOUNCE_MS = 250; @@ -250,3 +257,46 @@ export const teardownTelephonyGlobalShortcut = (): void => { unregisterTelephonyGlobalShortcut(); }; + +const applyTelephonyProtocolRegistration = (enabled: boolean): void => { + for (const scheme of TELEPHONY_SCHEMES) { + try { + if (enabled) { + app.setAsDefaultProtocolClient(scheme); + } else { + app.removeAsDefaultProtocolClient(scheme); + } + } catch (error) { + logger.warn( + `Failed to ${ + enabled ? 'register' : 'unregister' + } telephony protocol handler for ${scheme}:` + ); + logger.warn(error); + } + } +}; + +export const setupTelephonyProtocolHandlers = (): void => { + if (unsubscribeFromTelephonyEnabled) { + return; + } + + unsubscribeFromTelephonyEnabled = watch( + selectIsTelephonyEnabled, + (enabled) => { + applyTelephonyProtocolRegistration(enabled); + } + ); + + app.addListener('will-quit', teardownTelephonyProtocolHandlers); +}; + +export const teardownTelephonyProtocolHandlers = (): void => { + app.removeListener('will-quit', teardownTelephonyProtocolHandlers); + + if (unsubscribeFromTelephonyEnabled) { + unsubscribeFromTelephonyEnabled(); + unsubscribeFromTelephonyEnabled = null; + } +}; diff --git a/src/ui/actions.ts b/src/ui/actions.ts index 243abfee82..5b8fc3e859 100644 --- a/src/ui/actions.ts +++ b/src/ui/actions.ts @@ -96,6 +96,8 @@ export const SETTINGS_SET_MINIMIZE_ON_CLOSE_OPT_IN_CHANGED = 'settings/set-minimize-on-close-opt-in-changed'; export const SETTINGS_SET_IS_TRAY_ICON_ENABLED_CHANGED = 'settings/set-is-tray-icon-enabled-changed'; +export const SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED = + 'settings/set-is-telephony-enabled-changed'; export const SETTINGS_SET_IS_SIDE_BAR_ENABLED_CHANGED = 'settings/set-is-side-bar-enabled-changed'; export const SETTINGS_SET_IS_MENU_BAR_ENABLED_CHANGED = @@ -252,6 +254,7 @@ export type UiActionTypeToPayloadMap = { [SETTINGS_SET_INTERNALVIDEOCHATWINDOW_OPT_IN_CHANGED]: boolean; [SETTINGS_SET_MINIMIZE_ON_CLOSE_OPT_IN_CHANGED]: boolean; [SETTINGS_SET_IS_TRAY_ICON_ENABLED_CHANGED]: boolean; + [SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED]: boolean; [SETTINGS_SET_IS_SIDE_BAR_ENABLED_CHANGED]: boolean; [SETTINGS_SET_IS_MENU_BAR_ENABLED_CHANGED]: boolean; [SETTINGS_SET_IS_VIDEO_CALL_WINDOW_PERSISTENCE_ENABLED_CHANGED]: boolean; diff --git a/src/ui/components/SettingsView/GeneralTab.tsx b/src/ui/components/SettingsView/GeneralTab.tsx index 4d696d6436..3483fe6e3c 100644 --- a/src/ui/components/SettingsView/GeneralTab.tsx +++ b/src/ui/components/SettingsView/GeneralTab.tsx @@ -12,6 +12,7 @@ import { OutlookCalendarSyncInterval } from './features/OutlookCalendarSyncInter import { ReportErrors } from './features/ReportErrors'; import { ScreenCaptureFallback } from './features/ScreenCaptureFallback'; import { SideBar } from './features/SideBar'; +import { Telephony } from './features/Telephony'; import { TelephonyGlobalShortcut } from './features/TelephonyGlobalShortcut'; import { TelephonyServer } from './features/TelephonyServer'; import { ThemeAppearance } from './features/ThemeAppearance'; @@ -37,6 +38,7 @@ export const GeneralTab = () => ( + {!process.mas && } diff --git a/src/ui/components/SettingsView/features/Telephony.spec.tsx b/src/ui/components/SettingsView/features/Telephony.spec.tsx new file mode 100644 index 0000000000..4c95aa78a7 --- /dev/null +++ b/src/ui/components/SettingsView/features/Telephony.spec.tsx @@ -0,0 +1,61 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import type { RootState } from '../../../../store/rootReducer'; +import { SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED } from '../../../actions'; +import { Telephony } from './Telephony'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +type PartialState = Pick; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +describe('Telephony', () => { + it('renders unchecked when isTelephonyEnabled=false', () => { + const store = makeStore({ isTelephonyEnabled: false }); + render( + + + + ); + const toggle = screen.getByRole('checkbox'); + expect(toggle).not.toBeChecked(); + }); + + it('renders checked when isTelephonyEnabled=true', () => { + const store = makeStore({ isTelephonyEnabled: true }); + render( + + + + ); + const toggle = screen.getByRole('checkbox'); + expect(toggle).toBeChecked(); + }); + + it('dispatches SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED on toggle', () => { + const store = makeStore({ isTelephonyEnabled: false }); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + const toggle = screen.getByRole('checkbox'); + fireEvent.click(toggle); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED, + payload: true, + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/Telephony.tsx b/src/ui/components/SettingsView/features/Telephony.tsx new file mode 100644 index 0000000000..82fad0a6c6 --- /dev/null +++ b/src/ui/components/SettingsView/features/Telephony.tsx @@ -0,0 +1,58 @@ +import { + ToggleSwitch, + Field, + FieldRow, + FieldLabel, + FieldHint, +} from '@rocket.chat/fuselage'; +import type { ChangeEvent } from 'react'; +import { useCallback, useId } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import type { Dispatch } from 'redux'; + +import type { RootAction } from '../../../../store/actions'; +import type { RootState } from '../../../../store/rootReducer'; +import { SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED } from '../../../actions'; + +type TelephonyProps = { + className?: string; +}; + +export const Telephony = (props: TelephonyProps) => { + const isTelephonyEnabled = useSelector( + ({ isTelephonyEnabled }: RootState) => isTelephonyEnabled + ); + const dispatch = useDispatch>(); + const { t } = useTranslation(); + const handleChange = useCallback( + (event: ChangeEvent) => { + const isChecked = event.currentTarget.checked; + dispatch({ + type: SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED, + payload: isChecked, + }); + }, + [dispatch] + ); + + const isTelephonyEnabledId = useId(); + + return ( + + + + {t('settings.options.telephony.title')} + + + + + {t('settings.options.telephony.description')} + + + ); +}; diff --git a/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.spec.tsx b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.spec.tsx index 1bef7945a6..5fef8fb3cb 100644 --- a/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.spec.tsx +++ b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.spec.tsx @@ -37,7 +37,9 @@ jest.mock('@rocket.chat/fuselage', () => ({ type PartialState = Pick< RootState, - 'telephonyGlobalShortcutConfig' | 'telephonyGlobalShortcutRegistrationStatus' + | 'telephonyGlobalShortcutConfig' + | 'telephonyGlobalShortcutRegistrationStatus' + | 'isTelephonyEnabled' >; const makeStore = (partial: PartialState) => { @@ -46,6 +48,7 @@ const makeStore = (partial: PartialState) => { }; const defaultState: PartialState = { + isTelephonyEnabled: true, telephonyGlobalShortcutConfig: { enabled: false, accelerator: null, @@ -225,6 +228,7 @@ describe('TelephonyGlobalShortcut', () => { it('shows registration failure feedback from main process status', () => { const store = makeStore({ + isTelephonyEnabled: true, telephonyGlobalShortcutConfig: { enabled: true, accelerator: 'CommandOrControl+Shift+D', diff --git a/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx index 3c6f02753f..17eb1863bb 100644 --- a/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx +++ b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx @@ -76,6 +76,9 @@ export const TelephonyGlobalShortcut = () => { ({ telephonyGlobalShortcutRegistrationStatus }: RootState) => telephonyGlobalShortcutRegistrationStatus ); + const isTelephonyEnabled = useSelector( + ({ isTelephonyEnabled }: RootState) => isTelephonyEnabled + ); const [draftAccelerator, setDraftAccelerator] = useState( telephonyGlobalShortcutConfig.accelerator ?? '' ); @@ -174,6 +177,7 @@ export const TelephonyGlobalShortcut = () => { { />