diff --git a/platforms/web/lib/useListeners/event.test.ts b/platforms/web/lib/useListeners/event.test.ts index 6c9b1299a..643ddc008 100644 --- a/platforms/web/lib/useListeners/event.test.ts +++ b/platforms/web/lib/useListeners/event.test.ts @@ -19,6 +19,7 @@ import init, { new_composer_model } from '../../generated/wysiwyg'; import { extractActionStates, handleKeyDown } from './event'; import { FormatBlockEvent } from './types'; import { FormattingFunctions } from '../types'; +import { WINDOWS_UA, mockUserAgent } from '../utils.test'; beforeAll(async () => { await init(); @@ -44,6 +45,16 @@ describe('getFormattingState', () => { }); describe('handleKeyDown', () => { + let originalUserAgent = ''; + + beforeAll(() => { + originalUserAgent = navigator.userAgent; + }); + + afterAll(() => { + mockUserAgent(originalUserAgent); + }); + it.each([ ['formatBold', { ctrlKey: true, key: 'b' }], ['formatBold', { metaKey: true, key: 'b' }], @@ -60,21 +71,29 @@ describe('handleKeyDown', () => { ['sendMessage', { ctrlKey: true, key: 'Enter' }], ['sendMessage', { metaKey: true, key: 'Enter' }], ['formatStrikeThrough', { shiftKey: true, altKey: true, key: '5' }], - ])('Should dispatch %s when %o', async (expected, input) => { - const elem = document.createElement('input'); - const event = new KeyboardEvent('keydown', input); - - const result = new Promise((resolve) => { - elem.addEventListener('wysiwygInput', (({ - detail: { blockType }, - }: FormatBlockEvent) => { - resolve(blockType); - }) as EventListener); - }); + ['deleteWordBackward', { ctrlKey: true, key: 'Backspace' }, WINDOWS_UA], + ])( + 'Should dispatch %s when %o', + async (expected, input, userAgent?: string) => { + if (userAgent) { + mockUserAgent(userAgent); + } - const model = new_composer_model(); + const elem = document.createElement('input'); + const event = new KeyboardEvent('keydown', input); - handleKeyDown(event, elem, model, {} as FormattingFunctions); - expect(await result).toBe(expected); - }); + const result = new Promise((resolve) => { + elem.addEventListener('wysiwygInput', (({ + detail: { blockType }, + }: FormatBlockEvent) => { + resolve(blockType); + }) as EventListener); + }); + + const model = new_composer_model(); + + handleKeyDown(event, elem, model, {} as FormattingFunctions); + expect(await result).toBe(expected); + }, + ); }); diff --git a/platforms/web/lib/useListeners/event.ts b/platforms/web/lib/useListeners/event.ts index 30e8b2107..03c53b885 100644 --- a/platforms/web/lib/useListeners/event.ts +++ b/platforms/web/lib/useListeners/event.ts @@ -37,6 +37,7 @@ import { TestUtilities } from '../useTestCases/types'; import { AllActionStates } from '../types'; import { mapToAllActionStates } from './utils'; import { AtRoomSuggestionEvent, LinkEvent, SuggestionEvent } from './types'; +import { getUserOperatingSystem } from '../utils'; /** * Send a custom event named wysiwygInput @@ -82,6 +83,16 @@ function getInputFromKeyDown( } } + const operatingSystem = getUserOperatingSystem(); + if (operatingSystem === 'Windows' || operatingSystem === 'Linux') { + if (e.ctrlKey) { + switch (e.key) { + case 'Backspace': + return 'deleteWordBackward'; + } + } + } + if (e.ctrlKey || e.metaKey) { switch (e.key) { case 'b': diff --git a/platforms/web/lib/utils.test.ts b/platforms/web/lib/utils.test.ts new file mode 100644 index 000000000..5c5a24c4a --- /dev/null +++ b/platforms/web/lib/utils.test.ts @@ -0,0 +1,81 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { afterAll, beforeAll } from 'vitest'; + +import { getUserOperatingSystem } from './utils'; + +export const mockUserAgent = (ua: string) => { + Object.defineProperty(window.navigator, 'userAgent', { + value: ua, + writable: true, + }); +}; + +export const WINDOWS_UA = + // eslint-disable-next-line max-len + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246'; + +export const MAC_OS_UA = + // eslint-disable-next-line max-len + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9'; + +export const LINUX_UA = + // eslint-disable-next-line max-len + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1'; + +export const IOS_UA = + // eslint-disable-next-line max-len + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/69.0.3497.105 Mobile/15E148 Safari/605.1'; + +export const ANDROID_UA = + // eslint-disable-next-line max-len + 'Mozilla/5.0 (Linux; Android 13; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36'; + +describe('utils', () => { + describe('getUserOperatingSystem', () => { + let originalUserAgent = ''; + + beforeAll(() => { + originalUserAgent = navigator.userAgent; + }); + + afterAll(() => { + mockUserAgent(originalUserAgent); + }); + + test('returns null for unknown operating systems', () => { + mockUserAgent('wut?!'); + const os = getUserOperatingSystem(); + expect(os).toBeNull(); + }); + + test.each([ + ['Windows', WINDOWS_UA], + ['macOS', MAC_OS_UA], + ['Linux', LINUX_UA], + ['iOS', IOS_UA], + ['Android', ANDROID_UA], + ])( + 'should correctly detect %s', + (expectedOperatingSystem, userAgent) => { + mockUserAgent(userAgent); + const os = getUserOperatingSystem(); + expect(os).toBe(expectedOperatingSystem); + }, + ); + }); +}); diff --git a/platforms/web/lib/utils.ts b/platforms/web/lib/utils.ts new file mode 100644 index 000000000..665060fee --- /dev/null +++ b/platforms/web/lib/utils.ts @@ -0,0 +1,42 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Finds the operating system of the user + * @returns {string|null} the operating system, `null` if the operating system is unknown + */ +export function getUserOperatingSystem(): + | 'Windows' + | 'macOS' + | 'Linux' + | 'iOS' + | 'Android' + | null { + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.includes('iphone') || userAgent.includes('ipad')) { + return 'iOS'; + } else if (userAgent.includes('android')) { + return 'Android'; + } else if (userAgent.includes('win')) { + return 'Windows'; + } else if (userAgent.includes('mac')) { + return 'macOS'; + } else if (userAgent.includes('linux')) { + return 'Linux'; + } else { + return null; + } +}