Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow keyboard shortcuts to work on native devices #17708

Merged
merged 16 commits into from
Apr 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions __mocks__/react-native-key-command.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const registerKeyCommands = () => {};
const unregisterKeyCommands = () => {};
const constants = {};
const eventEmitter = () => {};
const addListener = () => {};

export {
registerKeyCommands,
unregisterKeyCommands,
constants,
eventEmitter,
addListener,
};
32 changes: 32 additions & 0 deletions android/app/src/main/java/com/expensify/chat/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
10 changes: 10 additions & 0 deletions ios/NewExpensify/AppDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

#import "RCTBootSplash.h"
#import "RCTStartupTimer.h"
#import <HardwareShortcuts.h>

@interface AppDelegate () <UNUserNotificationCenterDelegate>

Expand Down Expand Up @@ -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
12 changes: 9 additions & 3 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1009,7 +1014,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Airship: c70eed50e429f97f5adb285423c7291fb7a032ae
AirshipFrameworkProxy: 2eefb77bb77b5120b0f48814b0d44439aa3ad415
boost: 57d2868c099736d80fcd648bf211b4431e51a558
boost: a7c83b31436843459a1961bfd74b96033dc77234
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
FBLazyVector: ff54429f0110d3c722630a98096ba689c39f6d5f
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1123,4 +1129,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: cd132e281e9e3d7e6ec2c99c08e6ec32b37886f8

COCOAPODS: 1.12.0
COCOAPODS: 1.11.3
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 59 additions & 2 deletions src/CONST.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
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';
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,
Expand Down Expand Up @@ -227,6 +238,7 @@ const CONST = {
CTRL: {
DEFAULT: 'control',
[PLATFORM_OS_MACOS]: 'meta',
[PLATFORM_IOS]: 'meta',
},
SHIFT: {
DEFAULT: 'shift',
Expand All @@ -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',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Expand Down Expand Up @@ -790,7 +847,7 @@ const CONST = {
WINDOWS: 'Windows',
MAC_OS: PLATFORM_OS_MACOS,
ANDROID: 'Android',
IOS: 'iOS',
IOS: PLATFORM_IOS,
LINUX: 'Linux',
NATIVE: 'Native',
},
Expand Down
30 changes: 15 additions & 15 deletions src/components/Button.js → src/components/Button/index.js
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -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);
}
Expand Down
19 changes: 19 additions & 0 deletions src/components/Button/validateSubmitShortcut/index.js
Original file line number Diff line number Diff line change
@@ -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;
Loading