From ca3588fbe7ba4d8461c0e1310d0bc1ec0f308aa5 Mon Sep 17 00:00:00 2001 From: Gerardo Date: Wed, 20 Dec 2023 18:53:21 +0100 Subject: [PATCH 1/9] Mobile - WPAndroidGlueCode - Implement BackHandler functionality --- .../mobile/WPAndroidGlue/WPAndroidGlueCode.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java index c0916d1417a34..b8c1b3ca05030 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java @@ -113,6 +113,7 @@ public class WPAndroidGlueCode { private OnToggleRedoButtonListener mOnToggleRedoButtonListener; private OnConnectionStatusEventListener mOnConnectionStatusEventListener; + private OnBackHandlerEventListener mOnBackHandlerEventListener; private boolean mIsEditorMounted; private String mContentHtml = ""; @@ -264,6 +265,10 @@ public interface OnConnectionStatusEventListener { boolean onRequestConnectionStatus(); } + public interface OnBackHandlerEventListener { + void onBackHandler(); + } + public void mediaSelectionCancelled() { mAppendsMultipleSelectedToSiblingBlocks = false; } @@ -700,6 +705,7 @@ public void attachToContainer(ViewGroup viewGroup, OnToggleUndoButtonListener onToggleUndoButtonListener, OnToggleRedoButtonListener onToggleRedoButtonListener, OnConnectionStatusEventListener onConnectionStatusEventListener, + OnBackHandlerEventListener onBackHandlerEventListener, boolean isDarkMode) { MutableContextWrapper contextWrapper = (MutableContextWrapper) mReactRootView.getContext(); contextWrapper.setBaseContext(viewGroup.getContext()); @@ -726,6 +732,7 @@ public void attachToContainer(ViewGroup viewGroup, mOnToggleUndoButtonListener = onToggleUndoButtonListener; mOnToggleRedoButtonListener = onToggleRedoButtonListener; mOnConnectionStatusEventListener = onConnectionStatusEventListener; + mOnBackHandlerEventListener = onBackHandlerEventListener; sAddCookiesInterceptor.setOnAuthHeaderRequestedListener(onAuthHeaderRequestedListener); @@ -776,7 +783,7 @@ public void onResume(final Fragment fragment, final Activity activity) { @Override public void invokeDefaultOnBackPressed() { if (fragment.isAdded()) { - activity.onBackPressed(); + mOnBackHandlerEventListener.onBackHandler(); } } }); @@ -804,6 +811,12 @@ public void onDestroy(Activity activity) { } } + public void onBackPressed() { + if (mReactInstanceManager != null) { + mReactInstanceManager.onBackPressed(); + } + } + public void showDevOptionsDialog() { mReactInstanceManager.showDevOptionsDialog(); } From acc52beb6eae94dddf208ddabc894b0c1acc4fd6 Mon Sep 17 00:00:00 2001 From: Gerardo Date: Wed, 20 Dec 2023 19:32:35 +0100 Subject: [PATCH 2/9] Mobile - Editor Provider - Add BackHandler listener to unselect blocks --- .../src/components/provider/index.native.js | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/components/provider/index.native.js b/packages/editor/src/components/provider/index.native.js index bbd710f031c84..17a7a55bfedd4 100644 --- a/packages/editor/src/components/provider/index.native.js +++ b/packages/editor/src/components/provider/index.native.js @@ -1,6 +1,7 @@ /** * External dependencies */ +import { BackHandler } from 'react-native'; import memize from 'memize'; import { SafeAreaProvider } from 'react-native-safe-area-context'; @@ -79,6 +80,8 @@ class NativeEditorProvider extends Component { this.post ); + this.onHardwareBackPress = this.onHardwareBackPress.bind( this ); + this.getEditorSettings = memize( ( settings, capabilities ) => ( { ...settings, @@ -191,6 +194,11 @@ class NativeEditorProvider extends Component { this.setState( { isHelpVisible: true } ); } ); + this.hardwareBackPressListener = BackHandler.addEventListener( + 'hardwareBackPress', + this.onHardwareBackPress + ); + // Request current block impressions from native app. requestBlockTypeImpressions( ( storedImpressions ) => { const impressions = { ...NEW_BLOCK_TYPES, ...storedImpressions }; @@ -250,6 +258,10 @@ class NativeEditorProvider extends Component { if ( this.subscriptionParentShowEditorHelp ) { this.subscriptionParentShowEditorHelp.remove(); } + + if ( this.hardwareBackPressListener ) { + this.hardwareBackPressListener.remove(); + } } getThemeColors( { rawStyles, rawFeatures } ) { @@ -280,6 +292,16 @@ class NativeEditorProvider extends Component { } } + onHardwareBackPress() { + const { clearSelectedBlock, selectedBlockIndex } = this.props; + + if ( selectedBlockIndex !== -1 ) { + clearSelectedBlock(); + return true; + } + return false; + } + serializeToNativeAction() { const title = this.props.title; let html; @@ -397,8 +419,12 @@ const ComposedNativeProvider = compose( [ withDispatch( ( dispatch ) => { const { editPost, resetEditorBlocks, updateEditorSettings } = dispatch( editorStore ); - const { updateSettings, insertBlock, replaceBlock } = - dispatch( blockEditorStore ); + const { + clearSelectedBlock, + updateSettings, + insertBlock, + replaceBlock, + } = dispatch( blockEditorStore ); const { switchEditorMode } = dispatch( editPostStore ); const { addEntities, receiveEntityRecords } = dispatch( coreStore ); const { createSuccessNotice, createErrorNotice } = @@ -411,6 +437,7 @@ const ComposedNativeProvider = compose( [ insertBlock, createSuccessNotice, createErrorNotice, + clearSelectedBlock, editTitle( title ) { editPost( { title } ); }, From 01112e924bc85525fa64e0cc1c5d2fe9ecb0661e Mon Sep 17 00:00:00 2001 From: Gerardo Date: Wed, 20 Dec 2023 19:32:48 +0100 Subject: [PATCH 3/9] Mobile - Jest setup - Adds BackHandler mocks --- test/native/setup.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/native/setup.js b/test/native/setup.js index 8bfa8fb0626f2..bf11c852cfa94 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -282,3 +282,9 @@ jest.mock( '@wordpress/compose', () => { jest.spyOn( Image, 'getSize' ).mockImplementation( ( url, success ) => success( 0, 0 ) ); + +jest.mock( 'react-native/Libraries/Utilities/BackHandler', () => { + return jest.requireActual( + 'react-native/Libraries/Utilities/__mocks__/BackHandler.js' + ); +} ); From da56d17aa3dc6dbacbe6b2bb9517b35b3ccbfaeb Mon Sep 17 00:00:00 2001 From: Gerardo Date: Wed, 20 Dec 2023 19:33:02 +0100 Subject: [PATCH 4/9] Mobile - Edit post tests - Adds test for unselecting blocks using the hardware back button --- packages/edit-post/src/test/editor.native.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/edit-post/src/test/editor.native.js b/packages/edit-post/src/test/editor.native.js index 7faeb2e51ab4b..20eacc4bdf1db 100644 --- a/packages/edit-post/src/test/editor.native.js +++ b/packages/edit-post/src/test/editor.native.js @@ -11,6 +11,7 @@ import { screen, setupCoreBlocks, } from 'test/helpers'; +import { BackHandler } from 'react-native'; /** * WordPress dependencies @@ -129,4 +130,20 @@ describe( 'Editor', () => { // Assert expect( getEditorHtml() ).toMatchSnapshot(); } ); + + it( 'unselects current block when tapping on the hardware back button', async () => { + // Arrange + await initializeEditor(); + await addBlock( screen, 'Spacer' ); + + // Act + act( () => { + BackHandler.mockPressBack(); + } ); + + // Assert + const openBlockSettingsButton = + screen.queryAllByLabelText( 'Open Settings' ); + expect( openBlockSettingsButton.length ).toBe( 0 ); + } ); } ); From ce40897a0654e9bb9c2c17c5581f60cd6ba4a525 Mon Sep 17 00:00:00 2001 From: Gerardo Date: Fri, 22 Dec 2023 13:11:22 +0100 Subject: [PATCH 5/9] Track when it should handle the back press button for cases when there's another activity instanced and the editor is in the background --- .../mobile/WPAndroidGlue/WPAndroidGlueCode.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java index b8c1b3ca05030..8bffc6b28c00d 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java @@ -135,6 +135,7 @@ public class WPAndroidGlueCode { private boolean mIsDarkMode; private Consumer mExceptionLogger; private Consumer mBreadcrumbLogger; + private boolean mShouldHandleBackPress = false; public void onCreate(Context context) { SoLoader.init(context, /* native exopackage */ false); @@ -148,6 +149,10 @@ public boolean hasReactContext() { return mReactContext != null; } + public boolean shouldHandleBackPress() { + return mShouldHandleBackPress; + } + public boolean isContentChanged() { return mContentChanged; } @@ -768,6 +773,7 @@ private void refocus() { } public void onPause(Activity activity) { + mShouldHandleBackPress = false; if (mReactInstanceManager != null) { // get the focused view so we re-focus it later if needed. WeakReference so we don't leak it. mLastFocusedView = new WeakReference<>(mReactRootView.findFocus()); @@ -777,6 +783,7 @@ public void onPause(Activity activity) { } public void onResume(final Fragment fragment, final Activity activity) { + mShouldHandleBackPress = true; if (mReactInstanceManager != null) { mReactInstanceManager.onHostResume(activity, new DefaultHardwareBackBtnHandler() { @@ -791,11 +798,13 @@ public void invokeDefaultOnBackPressed() { } public void onDetach(Activity activity) { + mShouldHandleBackPress = false; mReactInstanceManager.onHostDestroy(activity); mRnReactNativeGutenbergBridgePackage.getRNReactNativeGutenbergBridgeModule().notifyModalClosed(); } public void onDestroy(Activity activity) { + mShouldHandleBackPress = false; if (mReactRootView != null) { mReactRootView.unmountReactApplication(); mReactRootView = null; From 194c1f7a9c47d6256d2be51155b0e0357af53afa Mon Sep 17 00:00:00 2001 From: Gerardo Date: Wed, 27 Dec 2023 12:54:27 +0100 Subject: [PATCH 6/9] Mobile - Keyboard Handling on Android - Set currentFocusedElement to null when hiding the soft keyboard. It also adds a check to not attempt to show the soft keyboard if there's no element set in currentFocusedElement --- packages/react-native-aztec/src/AztecInputState.js | 5 ++++- packages/react-native-bridge/index.js | 12 +++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/react-native-aztec/src/AztecInputState.js b/packages/react-native-aztec/src/AztecInputState.js index ca752d36e3d04..b688ef04ce26e 100644 --- a/packages/react-native-aztec/src/AztecInputState.js +++ b/packages/react-native-aztec/src/AztecInputState.js @@ -187,7 +187,10 @@ export const blurOnUnmount = ( element ) => { } }; -const dismissKeyboardDebounce = debounce( () => hideAndroidSoftKeyboard(), 0 ); +const dismissKeyboardDebounce = debounce( () => { + hideAndroidSoftKeyboard(); + currentFocusedElement = null; +}, 0 ); /** * Unfocuses the current focused element. diff --git a/packages/react-native-bridge/index.js b/packages/react-native-bridge/index.js index ffdd07a1640f7..4ed7664e60fa4 100644 --- a/packages/react-native-bridge/index.js +++ b/packages/react-native-bridge/index.js @@ -3,6 +3,11 @@ */ import { NativeModules, NativeEventEmitter, Platform } from 'react-native'; +/** + * WordPress dependencies + */ +import RCTAztecView from '@wordpress/react-native-aztec'; + const { RNReactNativeGutenbergBridge } = NativeModules; const isIOS = Platform.OS === 'ios'; const isAndroid = Platform.OS === 'android'; @@ -489,7 +494,12 @@ export function showAndroidSoftKeyboard() { return; } - RNReactNativeGutenbergBridge.showAndroidSoftKeyboard(); + const hasFocusedTextInput = + RCTAztecView.InputState.getCurrentFocusedElement() !== null; + + if ( hasFocusedTextInput ) { + RNReactNativeGutenbergBridge.showAndroidSoftKeyboard(); + } } /** From cc77161ebfc89667fd66848a670ca6fceebbee3b Mon Sep 17 00:00:00 2001 From: Gerardo Date: Wed, 27 Dec 2023 12:55:21 +0100 Subject: [PATCH 7/9] Update Changelog --- packages/react-native-editor/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index fc12b7df655cd..474060cb558e9 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -17,6 +17,7 @@ For each user feature we should also add a importance categorization label to i - [**] Fix crash when sharing unsupported media types on Android [#56791] - [**] Fix regressions with wrapper props and font size customization [#56985] - [***] Avoid keyboard dismiss when interacting with text blocks [#57070] +- [*] Unselect blocks using the hardware back button (Android) [#57279] ## 1.109.3 - [**] Fix duplicate/unresponsive options in font size settings. [#56985] From 00597901147cff714288e3016233fa35097e6ece Mon Sep 17 00:00:00 2001 From: Gerardo Date: Tue, 2 Jan 2024 14:47:20 +0100 Subject: [PATCH 8/9] Use InputState isFocused method instead of manually checking if there's a focused element --- packages/react-native-bridge/index.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-native-bridge/index.js b/packages/react-native-bridge/index.js index 4ed7664e60fa4..de5eb78516ead 100644 --- a/packages/react-native-bridge/index.js +++ b/packages/react-native-bridge/index.js @@ -494,8 +494,7 @@ export function showAndroidSoftKeyboard() { return; } - const hasFocusedTextInput = - RCTAztecView.InputState.getCurrentFocusedElement() !== null; + const hasFocusedTextInput = RCTAztecView.InputState.isFocused(); if ( hasFocusedTextInput ) { RNReactNativeGutenbergBridge.showAndroidSoftKeyboard(); From 63a0e09d6d1d3fda10ce98b84b010ea7a1ca14ce Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 2 Jan 2024 16:35:57 +0100 Subject: [PATCH 9/9] [RNMobile] Ensure Aztec input state function `blurOnUnmount` updates its state (#57486) * Ensure `blurOnUnmount` updates internal input state * Add unit test to cover `blurOnUnmount` logic --- .../react-native-aztec/src/AztecInputState.js | 28 ++++++++++--------- .../src/test/AztecInputState.test.js | 13 +++++++++ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/react-native-aztec/src/AztecInputState.js b/packages/react-native-aztec/src/AztecInputState.js index b688ef04ce26e..f76f8ed4f2450 100644 --- a/packages/react-native-aztec/src/AztecInputState.js +++ b/packages/react-native-aztec/src/AztecInputState.js @@ -142,7 +142,7 @@ export const focus = ( element ) => { // will take precedence and cancels pending blur events. blur.cancel(); // Similar to blur events, we also need to cancel potential keyboard dismiss. - dismissKeyboardDebounce.cancel(); + blurOnUnmountDebounce.cancel(); TextInputState.focusTextInput( element ); notifyInputChange(); @@ -164,13 +164,6 @@ export const blur = debounce( ( element ) => { /** * Unfocuses the specified element in case it's about to be unmounted. * - * On iOS text inputs are automatically unfocused and keyboard dismissed when they - * are removed. However, this is not the case on Android, where text inputs are - * unfocused but the keyboard remains open. - * - * For dismissing the keyboard, we use debounce to avoid conflicts with the focus - * event when both are triggered at the same time. - * * Note that we can't trigger the blur event, as it's likely that the Aztec view is no * longer available when the event is executed and will produce an exception. * @@ -181,15 +174,24 @@ export const blurOnUnmount = ( element ) => { // If a blur event was triggered before unmount, we need to cancel them to avoid // exceptions. blur.cancel(); - if ( Platform.OS === 'android' ) { - dismissKeyboardDebounce(); - } + blurOnUnmountDebounce(); } }; -const dismissKeyboardDebounce = debounce( () => { - hideAndroidSoftKeyboard(); +// For updating the input state and dismissing the keyboard, we use debounce to avoid +// conflicts with the focus event when both are triggered at the same time. +const blurOnUnmountDebounce = debounce( () => { + // At this point, the text input will be destroyed but it's still focused. Hence, we + // have to explicitly notify listeners and update internal input state. + notifyListeners( { isFocused: false } ); currentFocusedElement = null; + + // On iOS text inputs are automatically unfocused and keyboard dismissed when they + // are removed. However, this is not the case on Android, where text inputs are + // unfocused but the keyboard remains open. + if ( Platform.OS === 'android' ) { + hideAndroidSoftKeyboard(); + } }, 0 ); /** diff --git a/packages/react-native-aztec/src/test/AztecInputState.test.js b/packages/react-native-aztec/src/test/AztecInputState.test.js index e95d25a695c96..1458508a892e8 100644 --- a/packages/react-native-aztec/src/test/AztecInputState.test.js +++ b/packages/react-native-aztec/src/test/AztecInputState.test.js @@ -12,6 +12,7 @@ import { isFocused, focus, blur, + blurOnUnmount, notifyInputChange, removeFocusChangeListener, } from '../AztecInputState'; @@ -101,4 +102,16 @@ describe( 'Aztec Input State', () => { jest.runAllTimers(); expect( TextInputState.blurTextInput ).toHaveBeenCalledWith( ref ); } ); + + it( 'unfocuses an element when unmounted', () => { + const listener = jest.fn(); + addFocusChangeListener( listener ); + + updateCurrentFocusedInput( ref ); + blurOnUnmount( ref ); + jest.runAllTimers(); + + expect( listener ).toHaveBeenCalledWith( { isFocused: false } ); + expect( isFocused() ).toBeFalsy(); + } ); } );