diff --git a/__mocks__/react-native-key-command.js b/__mocks__/react-native-key-command.js new file mode 100644 index 0000000000000..092ab120a1425 --- /dev/null +++ b/__mocks__/react-native-key-command.js @@ -0,0 +1,13 @@ +const registerKeyCommands = () => {}; +const unregisterKeyCommands = () => {}; +const constants = {}; +const eventEmitter = () => {}; +const addListener = () => {}; + +export { + registerKeyCommands, + unregisterKeyCommands, + constants, + eventEmitter, + addListener, +}; diff --git a/android/app/src/main/java/com/expensify/chat/MainActivity.java b/android/app/src/main/java/com/expensify/chat/MainActivity.java index bd90ee9abd02c..b4eb483f8de67 100644 --- a/android/app/src/main/java/com/expensify/chat/MainActivity.java +++ b/android/app/src/main/java/com/expensify/chat/MainActivity.java @@ -2,7 +2,9 @@ import android.os.Bundle; import android.content.pm.ActivityInfo; +import android.view.KeyEvent; import com.expensify.chat.bootsplash.BootSplash; +import com.expensify.reactnativekeycommand.KeyCommandModule; import com.facebook.react.ReactActivity; import com.facebook.react.ReactActivityDelegate; import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; @@ -44,4 +46,34 @@ protected void onCreate(Bundle savedInstanceState) { setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); } } + + /** + * This method is called when a key down event has occurred. + * Forwards the event to the KeyCommandModule + */ + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + // Disabling hardware ESCAPE support which is handled by Android + if (event.getKeyCode() == KeyEvent.KEYCODE_ESCAPE) { + return false; + } + KeyCommandModule.getInstance().onKeyDownEvent(keyCode, event); + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyLongPress(int keyCode, KeyEvent event) { + // Disabling hardware ESCAPE support which is handled by Android + if (event.getKeyCode() == KeyEvent.KEYCODE_ESCAPE) { return false; } + KeyCommandModule.getInstance().onKeyDownEvent(keyCode, event); + return super.onKeyLongPress(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + // Disabling hardware ESCAPE support which is handled by Android + if (event.getKeyCode() == KeyEvent.KEYCODE_ESCAPE) { return false; } + KeyCommandModule.getInstance().onKeyDownEvent(keyCode, event); + return super.onKeyUp(keyCode, event); + } } \ No newline at end of file diff --git a/ios/NewExpensify/AppDelegate.mm b/ios/NewExpensify/AppDelegate.mm index 3238cfeb3c2ac..304de9aade342 100644 --- a/ios/NewExpensify/AppDelegate.mm +++ b/ios/NewExpensify/AppDelegate.mm @@ -8,6 +8,7 @@ #import "RCTBootSplash.h" #import "RCTStartupTimer.h" +#import @interface AppDelegate () @@ -89,4 +90,13 @@ - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { #endif } +// This methods is needed to support the hardware keyboard shortcuts +- (NSArray *)keyCommands { + return [HardwareShortcuts sharedInstance].keyCommands; +} + +- (void)handleKeyCommand:(UIKeyCommand *)keyCommand { + [[HardwareShortcuts sharedInstance] handleKeyCommand:keyCommand]; +} + @end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 6d94ed9c31aa0..e8ee90c6cd71e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -514,6 +514,8 @@ PODS: - React - react-native-image-picker (5.1.0): - React-Core + - react-native-key-command (1.0.0): + - React-Core - react-native-netinfo (8.3.1): - React-Core - react-native-pdf (6.6.2): @@ -769,6 +771,7 @@ DEPENDENCIES: - react-native-flipper (from `../node_modules/react-native-flipper`) - "react-native-image-manipulator (from `../node_modules/@oguzhnatly/react-native-image-manipulator`)" - react-native-image-picker (from `../node_modules/react-native-image-picker`) + - react-native-key-command (from `../node_modules/react-native-key-command`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-pdf (from `../node_modules/react-native-pdf`) - react-native-performance (from `../node_modules/react-native-performance`) @@ -921,6 +924,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@oguzhnatly/react-native-image-manipulator" react-native-image-picker: :path: "../node_modules/react-native-image-picker" + react-native-key-command: + :path: "../node_modules/react-native-key-command" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" react-native-pdf: @@ -1009,7 +1014,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Airship: c70eed50e429f97f5adb285423c7291fb7a032ae AirshipFrameworkProxy: 2eefb77bb77b5120b0f48814b0d44439aa3ad415 - boost: 57d2868c099736d80fcd648bf211b4431e51a558 + boost: a7c83b31436843459a1961bfd74b96033dc77234 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 FBLazyVector: ff54429f0110d3c722630a98096ba689c39f6d5f @@ -1052,7 +1057,7 @@ SPEC CHECKSUMS: Permission-LocationWhenInUse: 3ba99e45c852763f730eabecec2870c2382b7bd4 Plaid: 7d340abeadb46c7aa1a91f896c5b22395a31fcf2 PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef - RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 + RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCTRequired: e9e7b8b45aa9bedb2fdad71740adf07a7265b9be RCTTypeSafety: 9ae0e9206625e995f0df4d5b9ddc94411929fb30 React: a71c8e1380f07e01de721ccd52bcf9c03e81867d @@ -1074,6 +1079,7 @@ SPEC CHECKSUMS: react-native-flipper: dc5290261fbeeb2faec1bdc57ae6dd8d562e1de4 react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b + react-native-key-command: 0b3aa7c9f5c052116413e81dce33a3b2153a6c5d react-native-netinfo: 1a6035d3b9780221d407c277ebfb5722ace00658 react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa react-native-performance: 224bd53e6a835fda4353302cf891d088a0af7406 @@ -1123,4 +1129,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: cd132e281e9e3d7e6ec2c99c08e6ec32b37886f8 -COCOAPODS: 1.12.0 +COCOAPODS: 1.11.3 diff --git a/package-lock.json b/package-lock.json index 4bc674249987e..e86eac518485b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#6b5ab5110dc3ed554f8eafbc38d7d87c17147972", + "react-native-key-command": "^1.0.0", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.39", @@ -34696,6 +34697,21 @@ "integrity": "sha512-jNNpW5byieb7pb/l0HRvmCav4BtzpTzgC+ybT+Wbi2yyroOukveVvnjwWnmoOeuGynsYB4Yt5eGrWZnPnJSwqQ==", "license": "MIT" }, + "node_modules/react-native-key-command": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.0.tgz", + "integrity": "sha512-gjtzvJmgssKQ6YWoSiIIM37N/8fxtEUpvrwMZL9YTOg+WSTyJP5C9jIkHiT0KgMmfBylxwoJOCjche9TiNcdDQ==", + "dependencies": { + "events": "^3.3.0", + "underscore": "^1.13.4" + }, + "peerDependencies": { + "react": "^18.1.0", + "react-dom": "18.1.0", + "react-native": "^0.70.4", + "react-native-web": "^0.18.1" + } + }, "node_modules/react-native-localize": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/react-native-localize/-/react-native-localize-2.2.6.tgz", @@ -64383,6 +64399,15 @@ "integrity": "sha512-jNNpW5byieb7pb/l0HRvmCav4BtzpTzgC+ybT+Wbi2yyroOukveVvnjwWnmoOeuGynsYB4Yt5eGrWZnPnJSwqQ==", "from": "react-native-image-size@git+https://github.com/Expensify/react-native-image-size#6b5ab5110dc3ed554f8eafbc38d7d87c17147972" }, + "react-native-key-command": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.0.tgz", + "integrity": "sha512-gjtzvJmgssKQ6YWoSiIIM37N/8fxtEUpvrwMZL9YTOg+WSTyJP5C9jIkHiT0KgMmfBylxwoJOCjche9TiNcdDQ==", + "requires": { + "events": "^3.3.0", + "underscore": "^1.13.4" + } + }, "react-native-localize": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/react-native-localize/-/react-native-localize-2.2.6.tgz", diff --git a/package.json b/package.json index a5f13254cecc4..1c8fa922a614d 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#6b5ab5110dc3ed554f8eafbc38d7d87c17147972", + "react-native-key-command": "^1.0.0", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.39", diff --git a/src/CONST.js b/src/CONST.js index 3eb58c94df35d..568aa09ebe6cb 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -1,5 +1,6 @@ import lodashGet from 'lodash/get'; import Config from 'react-native-config'; +import * as KeyCommand from 'react-native-key-command'; import * as Url from './libs/Url'; const CLOUDFRONT_DOMAIN = 'cloudfront.net'; @@ -7,11 +8,21 @@ const CLOUDFRONT_URL = `https://d2k5nsl2zxldvw.${CLOUDFRONT_DOMAIN}`; const ACTIVE_EXPENSIFY_URL = Url.addTrailingForwardSlash(lodashGet(Config, 'NEW_EXPENSIFY_URL', 'https://new.expensify.com')); const USE_EXPENSIFY_URL = 'https://use.expensify.com'; const PLATFORM_OS_MACOS = 'Mac OS'; +const PLATFORM_IOS = 'iOS'; const ANDROID_PACKAGE_NAME = 'com.expensify.chat'; const USA_COUNTRY_NAME = 'United States'; const CURRENT_YEAR = new Date().getFullYear(); const PULL_REQUEST_NUMBER = lodashGet(Config, 'PULL_REQUEST_NUMBER', ''); +const keyModifierControl = lodashGet(KeyCommand, 'constants.keyModifierControl', 'keyModifierControl'); +const keyModifierCommand = lodashGet(KeyCommand, 'constants.keyModifierCommand', 'keyModifierCommand'); +const keyModifierShiftControl = lodashGet(KeyCommand, 'constants.keyModifierShiftControl', 'keyModifierShiftControl'); +const keyModifierShiftCommand = lodashGet(KeyCommand, 'constants.keyModifierShiftCommand', 'keyModifierShiftCommand'); +const keyInputEscape = lodashGet(KeyCommand, 'constants.keyInputEscape', 'keyInputEscape'); +const keyInputEnter = lodashGet(KeyCommand, 'constants.keyInputEnter', 'keyInputEnter'); +const keyInputUpArrow = lodashGet(KeyCommand, 'constants.keyInputUpArrow', 'keyInputUpArrow'); +const keyInputDownArrow = lodashGet(KeyCommand, 'constants.keyInputDownArrow', 'keyInputDownArrow'); + const CONST = { ANDROID_PACKAGE_NAME, ANIMATED_TRANSITION: 300, @@ -227,6 +238,7 @@ const CONST = { CTRL: { DEFAULT: 'control', [PLATFORM_OS_MACOS]: 'meta', + [PLATFORM_IOS]: 'meta', }, SHIFT: { DEFAULT: 'shift', @@ -237,46 +249,91 @@ const CONST = { descriptionKey: 'search', shortcutKey: 'K', modifiers: ['CTRL'], + trigger: { + DEFAULT: {input: 'k', modifierFlags: keyModifierControl}, + [PLATFORM_OS_MACOS]: {input: 'k', modifierFlags: keyModifierCommand}, + [PLATFORM_IOS]: {input: 'k', modifierFlags: keyModifierCommand}, + }, }, NEW_GROUP: { descriptionKey: 'newGroup', shortcutKey: 'K', modifiers: ['CTRL', 'SHIFT'], + trigger: { + DEFAULT: {input: 'k', modifierFlags: keyModifierShiftControl}, + [PLATFORM_OS_MACOS]: {input: 'k', modifierFlags: keyModifierShiftCommand}, + [PLATFORM_IOS]: {input: 'k', modifierFlags: keyModifierShiftCommand}, + }, }, SHORTCUT_MODAL: { descriptionKey: 'openShortcutDialog', - shortcutKey: 'I', + shortcutKey: 'J', modifiers: ['CTRL'], + trigger: { + DEFAULT: {input: 'j', modifierFlags: keyModifierControl}, + [PLATFORM_OS_MACOS]: {input: 'j', modifierFlags: keyModifierCommand}, + [PLATFORM_IOS]: {input: 'j', modifierFlags: keyModifierCommand}, + }, }, ESCAPE: { descriptionKey: 'escape', shortcutKey: 'Escape', modifiers: [], + trigger: { + DEFAULT: {input: keyInputEscape}, + [PLATFORM_OS_MACOS]: {input: keyInputEscape}, + [PLATFORM_IOS]: {input: keyInputEscape}, + }, }, ENTER: { descriptionKey: null, shortcutKey: 'Enter', modifiers: [], + trigger: { + DEFAULT: {input: keyInputEnter}, + [PLATFORM_OS_MACOS]: {input: keyInputEnter}, + [PLATFORM_IOS]: {input: keyInputEnter}, + }, }, CTRL_ENTER: { descriptionKey: null, shortcutKey: 'Enter', modifiers: ['CTRL'], + trigger: { + DEFAULT: {input: keyInputEnter, modifierFlags: keyModifierControl}, + [PLATFORM_OS_MACOS]: {input: keyInputEnter, modifierFlags: keyModifierCommand}, + [PLATFORM_IOS]: {input: keyInputEnter, modifierFlags: keyModifierCommand}, + }, }, COPY: { descriptionKey: 'copy', shortcutKey: 'C', modifiers: ['CTRL'], + trigger: { + DEFAULT: {input: 'c', modifierFlags: keyModifierControl}, + [PLATFORM_OS_MACOS]: {input: 'c', modifierFlags: keyModifierCommand}, + [PLATFORM_IOS]: {input: 'c', modifierFlags: keyModifierCommand}, + }, }, ARROW_UP: { descriptionKey: null, shortcutKey: 'ArrowUp', modifiers: [], + trigger: { + DEFAULT: {input: keyInputUpArrow}, + [PLATFORM_OS_MACOS]: {input: keyInputUpArrow}, + [PLATFORM_IOS]: {input: keyInputUpArrow}, + }, }, ARROW_DOWN: { descriptionKey: null, shortcutKey: 'ArrowDown', modifiers: [], + trigger: { + DEFAULT: {input: keyInputDownArrow}, + [PLATFORM_OS_MACOS]: {input: keyInputDownArrow}, + [PLATFORM_IOS]: {input: keyInputDownArrow}, + }, }, TAB: { descriptionKey: null, @@ -790,7 +847,7 @@ const CONST = { WINDOWS: 'Windows', MAC_OS: PLATFORM_OS_MACOS, ANDROID: 'Android', - IOS: 'iOS', + IOS: PLATFORM_IOS, LINUX: 'Linux', NATIVE: 'Native', }, diff --git a/src/components/Button.js b/src/components/Button/index.js similarity index 93% rename from src/components/Button.js rename to src/components/Button/index.js index 817a1c1f9e7d3..3bdb53a115ece 100644 --- a/src/components/Button.js +++ b/src/components/Button/index.js @@ -1,19 +1,20 @@ import React, {Component} from 'react'; import {Pressable, ActivityIndicator, View} from 'react-native'; import PropTypes from 'prop-types'; -import styles from '../styles/styles'; -import themeColors from '../styles/themes/default'; -import OpacityView from './OpacityView'; -import Text from './Text'; -import KeyboardShortcut from '../libs/KeyboardShortcut'; -import Icon from './Icon'; -import CONST from '../CONST'; -import * as StyleUtils from '../styles/StyleUtils'; -import HapticFeedback from '../libs/HapticFeedback'; -import withNavigationFallback from './withNavigationFallback'; -import compose from '../libs/compose'; -import * as Expensicons from './Icon/Expensicons'; -import withNavigationFocus from './withNavigationFocus'; +import styles from '../../styles/styles'; +import themeColors from '../../styles/themes/default'; +import OpacityView from '../OpacityView'; +import Text from '../Text'; +import KeyboardShortcut from '../../libs/KeyboardShortcut'; +import Icon from '../Icon'; +import CONST from '../../CONST'; +import * as StyleUtils from '../../styles/StyleUtils'; +import HapticFeedback from '../../libs/HapticFeedback'; +import withNavigationFallback from '../withNavigationFallback'; +import compose from '../../libs/compose'; +import * as Expensicons from '../Icon/Expensicons'; +import withNavigationFocus from '../withNavigationFocus'; +import validateSubmitShortcut from './validateSubmitShortcut'; const propTypes = { /** The text for the button label */ @@ -157,10 +158,9 @@ class Button extends Component { // Setup and attach keypress handler for pressing the button with Enter key this.unsubscribe = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, (e) => { - if (!this.props.isFocused || this.props.isDisabled || this.props.isLoading || (e && e.target.nodeName === 'TEXTAREA')) { + if (!validateSubmitShortcut(this.props.isFocused, this.props.isDisabled, this.props.isLoading, e)) { return; } - e.preventDefault(); this.props.onPress(); }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, false, this.props.enterKeyEventListenerPriority, false); } diff --git a/src/components/Button/validateSubmitShortcut/index.js b/src/components/Button/validateSubmitShortcut/index.js new file mode 100644 index 0000000000000..bfe5c79483fa4 --- /dev/null +++ b/src/components/Button/validateSubmitShortcut/index.js @@ -0,0 +1,19 @@ +/** + * Validate if the submit shortcut should be triggered depending on the button state + * + * @param {boolean} isFocused Whether Button is on active screen + * @param {boolean} isDisabled Indicates whether the button should be disabled + * @param {boolean} isLoading Indicates whether the button should be disabled and in the loading state + * @param {Object} event Focused input event + * @returns {boolean} Returns `true` if the shortcut should be triggered + */ +function validateSubmitShortcut(isFocused, isDisabled, isLoading, event) { + if (!isFocused || isDisabled || isLoading || (event && event.target.nodeName === 'TEXTAREA')) { + return false; + } + + event.preventDefault(); + return true; +} + +export default validateSubmitShortcut; diff --git a/src/components/Button/validateSubmitShortcut/index.native.js b/src/components/Button/validateSubmitShortcut/index.native.js new file mode 100644 index 0000000000000..2822fa56d590f --- /dev/null +++ b/src/components/Button/validateSubmitShortcut/index.native.js @@ -0,0 +1,17 @@ +/** + * Validate if the submit shortcut should be triggered depending on the button state + * + * @param {boolean} isFocused Whether Button is on active screen + * @param {boolean} isDisabled Indicates whether the button should be disabled + * @param {boolean} isLoading Indicates whether the button should be disabled and in the loading state + * @returns {boolean} Returns `true` if the shortcut should be triggered + */ +function validateSubmitShortcut(isFocused, isDisabled, isLoading) { + if (!isFocused || isDisabled || isLoading) { + return false; + } + + return true; +} + +export default validateSubmitShortcut; diff --git a/src/components/KeyboardShortcutsModal.js b/src/components/KeyboardShortcutsModal.js index 81ad2f6428310..a5454e280f0d6 100644 --- a/src/components/KeyboardShortcutsModal.js +++ b/src/components/KeyboardShortcutsModal.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {View} from 'react-native'; +import {View, ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import HeaderWithCloseButton from './HeaderWithCloseButton'; @@ -34,18 +34,26 @@ const defaultProps = { class KeyboardShortcutsModal extends React.Component { componentDidMount() { - const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUT_MODAL; - this.unsubscribeShortcutModal = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { + const openShortcutModalConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUT_MODAL; + this.unsubscribeShortcutModal = KeyboardShortcut.subscribe(openShortcutModalConfig.shortcutKey, () => { ModalActions.close(); KeyboardShortcutsActions.showKeyboardShortcutModal(); - }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true); + }, openShortcutModalConfig.descriptionKey, openShortcutModalConfig.modifiers, true); + + const closeShortcutModalConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; + this.unsubscribeEscapeModal = KeyboardShortcut.subscribe(closeShortcutModalConfig.shortcutKey, () => { + ModalActions.close(); + KeyboardShortcutsActions.hideKeyboardShortcutModal(); + }, closeShortcutModalConfig.descriptionKey, closeShortcutModalConfig.modifiers, true, true); } componentWillUnmount() { - if (!this.unsubscribeShortcutModal) { - return; + if (this.unsubscribeShortcutModal) { + this.unsubscribeShortcutModal(); + } + if (this.unsubscribeEscapeModal) { + this.unsubscribeEscapeModal(); } - this.unsubscribeShortcutModal(); } /** @@ -85,7 +93,7 @@ class KeyboardShortcutsModal extends React.Component { onClose={KeyboardShortcutsActions.hideKeyboardShortcutModal} > - + {this.props.translate('keyboardShortcutModal.subtitle')} @@ -95,7 +103,7 @@ class KeyboardShortcutsModal extends React.Component { })} - + ); } diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js index 756bedaff93bc..7a2ae22ccf453 100644 --- a/src/components/ScreenWrapper/index.js +++ b/src/components/ScreenWrapper/index.js @@ -45,7 +45,7 @@ class ScreenWrapper extends React.Component { } Navigation.dismissModal(); - }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true); + }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, true); this.unsubscribeTransitionEnd = this.props.navigation.addListener('transitionEnd', (event) => { // Prevent firing the prop callback when user is exiting the page. diff --git a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.js b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.js new file mode 100644 index 0000000000000..338ce921221e4 --- /dev/null +++ b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.js @@ -0,0 +1,54 @@ +import _ from 'underscore'; +import getKeyEventModifiers from '../getKeyEventModifiers'; + +/** + * Checks if an event for that key is configured and if so, runs it. + * @param {Function} getDisplayName + * @param {Object} eventHandlers + * @param {Object} keycommandEvent + * @param {Event} event + * @private + */ +function bindHandlerToKeydownEvent(getDisplayName, eventHandlers, keycommandEvent, event) { + if (!(event instanceof KeyboardEvent)) { + return; + } + + const eventModifiers = getKeyEventModifiers(keycommandEvent); + const displayName = getDisplayName(keycommandEvent.input, eventModifiers); + + // Loop over all the callbacks + _.every(eventHandlers[displayName], (callback) => { + // Early return for excludedNodes + if (_.contains(callback.excludedNodes, event.target.nodeName)) { + return true; + } + + // If configured to do so, prevent input text control to trigger this event + if (!callback.captureOnInputs && ( + event.target.nodeName === 'INPUT' + || event.target.nodeName === 'TEXTAREA' + || event.target.contentEditable === 'true' + )) { + return true; + } + + // Determine if the event should bubble before executing the callback (which may have side-effects) + let shouldBubble = callback.shouldBubble || false; + if (_.isFunction(callback.shouldBubble)) { + shouldBubble = callback.shouldBubble(); + } + + if (_.isFunction(callback.callback)) { + callback.callback(event); + } + if (callback.shouldPreventDefault) { + event.preventDefault(); + } + + // If the event should not bubble, short-circuit the loop + return shouldBubble; + }); +} + +export default bindHandlerToKeydownEvent; diff --git a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.js b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.js new file mode 100644 index 0000000000000..de59c819c504a --- /dev/null +++ b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.js @@ -0,0 +1,33 @@ +import _ from 'underscore'; +import getKeyEventModifiers from '../getKeyEventModifiers'; + +/** + * Checks if an event for that key is configured and if so, runs it. + * @param {Function} getDisplayName + * @param {Object} eventHandlers + * @param {Object} keycommandEvent + * @param {Event} event + * @private + */ +function bindHandlerToKeydownEvent(getDisplayName, eventHandlers, keycommandEvent, event) { + const eventModifiers = getKeyEventModifiers(keycommandEvent); + const displayName = getDisplayName(keycommandEvent.input, eventModifiers); + + // Loop over all the callbacks + _.every(eventHandlers[displayName], (callback) => { + // Determine if the event should bubble before executing the callback (which may have side-effects) + let shouldBubble = callback.shouldBubble || false; + if (_.isFunction(callback.shouldBubble)) { + shouldBubble = callback.shouldBubble(); + } + + if (_.isFunction(callback.callback)) { + callback.callback(event); + } + + // If the event should not bubble, short-circuit the loop + return shouldBubble; + }); +} + +export default bindHandlerToKeydownEvent; diff --git a/src/libs/KeyboardShortcut/getKeyEventModifiers.js b/src/libs/KeyboardShortcut/getKeyEventModifiers.js new file mode 100644 index 0000000000000..7865d51a05079 --- /dev/null +++ b/src/libs/KeyboardShortcut/getKeyEventModifiers.js @@ -0,0 +1,27 @@ +import * as KeyCommand from 'react-native-key-command'; +import lodashGet from 'lodash/get'; + +/** + * Gets modifiers from a keyboard event. + * + * @param {Event} event + * @returns {Array} + */ +function getKeyEventModifiers(event) { + if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierControl', 'keyModifierControl')) { + return ['CONTROL']; + } + if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierCommand', 'keyModifierCommand')) { + return ['META']; + } + if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierShiftControl', 'keyModifierShiftControl')) { + return ['CONTROL', 'Shift']; + } + if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierShiftCommand', 'keyModifierShiftCommand')) { + return ['META', 'Shift']; + } + + return []; +} + +export default getKeyEventModifiers; diff --git a/src/libs/KeyboardShortcut/index.js b/src/libs/KeyboardShortcut/index.js index 39c3a49e06092..9ffd01bbc406b 100644 --- a/src/libs/KeyboardShortcut/index.js +++ b/src/libs/KeyboardShortcut/index.js @@ -1,9 +1,13 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import Str from 'expensify-common/lib/str'; +import * as KeyCommand from 'react-native-key-command'; +import bindHandlerToKeydownEvent from './bindHandlerToKeydownEvent'; import getOperatingSystem from '../getOperatingSystem'; import CONST from '../../CONST'; +const operatingSystem = getOperatingSystem(); + // Handlers for the various keyboard listeners we set up const eventHandlers = {}; @@ -17,29 +21,6 @@ function getDocumentedShortcuts() { return _.values(documentedShortcuts); } -/** - * Gets modifiers from a keyboard event. - * - * @param {Event} event - * @returns {Array} - */ -function getKeyEventModifiers(event) { - const modifiers = []; - if (event.shiftKey) { - modifiers.push('SHIFT'); - } - if (event.ctrlKey) { - modifiers.push('CONTROL'); - } - if (event.altKey) { - modifiers.push('ALT'); - } - if (event.metaKey) { - modifiers.push('META'); - } - return modifiers; -} - /** * Generates the normalized display name for keyboard shortcuts. * @@ -48,7 +29,23 @@ function getKeyEventModifiers(event) { * @returns {String} */ function getDisplayName(key, modifiers) { - let displayName = [key.toUpperCase()]; + let displayName = (() => { + // Type of key is string and the type of KeyCommand.constants.* is number | string. Use _.isEqual to match different types. + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputEnter', 'keyInputEnter').toString().toLowerCase())) { + return ['ENTER']; + } + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputEscape', 'keyInputEscape').toString().toLowerCase())) { + return ['ESCAPE']; + } + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputUpArrow', 'keyInputUpArrow').toString().toLowerCase())) { + return ['ARROWUP']; + } + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputDownArrow', 'keyInputDownArrow').toString().toLowerCase())) { + return ['ARROWDOWN']; + } + return [key.toUpperCase()]; + })(); + if (_.isString(modifiers)) { displayName.unshift(modifiers); } else if (_.isArray(modifiers)) { @@ -60,56 +57,19 @@ function getDisplayName(key, modifiers) { return displayName.join(' + '); } -/** - * Checks if an event for that key is configured and if so, runs it. - * @param {Event} event - * @private - */ -function bindHandlerToKeydownEvent(event) { - if (!(event instanceof KeyboardEvent)) { +_.each(CONST.KEYBOARD_SHORTCUTS, (shortcut) => { + const shortcutTrigger = lodashGet(shortcut, ['trigger', operatingSystem], lodashGet(shortcut, 'trigger.DEFAULT')); + + // If there is no trigger for the current OS nor a default trigger, then we don't need to do anything + if (!shortcutTrigger) { return; } - const eventModifiers = getKeyEventModifiers(event); - const displayName = getDisplayName(event.key, eventModifiers); - - // Loop over all the callbacks - _.every(eventHandlers[displayName], (callback) => { - // Early return for excludedNodes - if (_.contains(callback.excludedNodes, event.target.nodeName)) { - return true; - } - - // If configured to do so, prevent input text control to trigger this event - if (!callback.captureOnInputs && ( - event.target.nodeName === 'INPUT' - || event.target.nodeName === 'TEXTAREA' - || event.target.contentEditable === 'true' - )) { - return true; - } - - // Determine if the event should bubble before executing the callback (which may have side-effects) - let shouldBubble = callback.shouldBubble || false; - if (_.isFunction(callback.shouldBubble)) { - shouldBubble = callback.shouldBubble(); - } - - if (_.isFunction(callback.callback)) { - callback.callback(event); - } - if (callback.shouldPreventDefault) { - event.preventDefault(); - } - - // If the event should not bubble, short-circuit the loop - return shouldBubble; - }); -} - -// Make sure we don't add multiple listeners -document.removeEventListener('keydown', bindHandlerToKeydownEvent, {capture: true}); -document.addEventListener('keydown', bindHandlerToKeydownEvent, {capture: true}); + KeyCommand.addListener( + shortcutTrigger, + (keycommandEvent, event) => bindHandlerToKeydownEvent(getDisplayName, eventHandlers, keycommandEvent, event), + ); +}); /** * Unsubscribes a keyboard event handler. @@ -129,7 +89,6 @@ function unsubscribe(displayName, callbackID) { * @returns {Array} */ function getPlatformEquivalentForKeys(keys) { - const operatingSystem = getOperatingSystem(); return _.map(keys, (key) => { if (!_.has(CONST.PLATFORM_SPECIFIC_KEYS, key)) { return key; diff --git a/src/libs/KeyboardShortcut/index.native.js b/src/libs/KeyboardShortcut/index.native.js deleted file mode 100644 index 8c97f2daf3432..0000000000000 --- a/src/libs/KeyboardShortcut/index.native.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * This is a no-op component for native devices because they wouldn't be able to support keyboard shortcuts like - * a website. - */ -const KeyboardShortcut = { - subscribe() { - return () => {}; - }, - getDocumentedShortcuts() { return []; }, -}; - -export default KeyboardShortcut; diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index b863a928d4b07..80359741de851 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -233,7 +233,7 @@ class ReportActionCompose extends React.Component { } this.updateComment('', true); - }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true); + }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, true); this.setMaxLines(); this.updateComment(this.comment); diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js index ca830a406dcb9..19771c097d525 100644 --- a/src/pages/settings/AboutPage/AboutPage.js +++ b/src/pages/settings/AboutPage/AboutPage.js @@ -17,8 +17,8 @@ import Logo from '../../../../assets/images/new-expensify.svg'; import pkg from '../../../../package.json'; import * as Report from '../../../libs/actions/Report'; import * as Link from '../../../libs/actions/Link'; -import getPlatformSpecificMenuItems from './getPlatformSpecificMenuItems'; import compose from '../../../libs/compose'; +import * as KeyboardShortcuts from '../../../libs/actions/KeyboardShortcuts'; import * as Environment from '../../../libs/Environment/Environment'; const propTypes = { @@ -27,8 +27,6 @@ const propTypes = { }; const AboutPage = (props) => { - const platformSpecificMenuItems = getPlatformSpecificMenuItems(props.isSmallScreenWidth); - const menuItems = [ { translationKey: 'initialSettingsPage.aboutPage.appDownloadLinks', @@ -37,7 +35,11 @@ const AboutPage = (props) => { Navigation.navigate(ROUTES.SETTINGS_APP_DOWNLOAD_LINKS); }, }, - ...platformSpecificMenuItems, + { + translationKey: 'initialSettingsPage.aboutPage.viewKeyboardShortcuts', + icon: Expensicons.Keyboard, + action: KeyboardShortcuts.showKeyboardShortcutModal, + }, { translationKey: 'initialSettingsPage.aboutPage.viewTheCode', icon: Expensicons.Eye, diff --git a/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.js b/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.js deleted file mode 100644 index 52f8ffa2250ff..0000000000000 --- a/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.js +++ /dev/null @@ -1,15 +0,0 @@ -import * as KeyboardShortcuts from '../../../../libs/actions/KeyboardShortcuts'; -import * as Expensicons from '../../../../components/Icon/Expensicons'; - -export default (isSmallScreenWidth) => { - if (isSmallScreenWidth) { - return []; - } - return [ - { - translationKey: 'initialSettingsPage.aboutPage.viewKeyboardShortcuts', - icon: Expensicons.Keyboard, - action: KeyboardShortcuts.showKeyboardShortcutModal, - }, - ]; -}; diff --git a/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.native.js b/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.native.js deleted file mode 100644 index 4ba9480748fc5..0000000000000 --- a/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.native.js +++ /dev/null @@ -1 +0,0 @@ -export default () => []; diff --git a/src/styles/styles.js b/src/styles/styles.js index 1c7d8656f9113..0579dd7438f6e 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -2807,7 +2807,9 @@ const styles = { keyboardShortcutModalContainer: { maxHeight: '100%', - flex: '0 0 auto', + flexShrink: 0, + flexGrow: 0, + flexBasis: 'auto', }, keyboardShortcutTableWrapper: {