From 9298283e2b1d4909961fe1641ab0d67ffc27d438 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 16 Feb 2022 17:31:22 -0800 Subject: [PATCH 1/9] deps: Migrate to @react-native-clipboard/clipboard `Clipboard` in RN core is deprecated; see warning at https://reactnative.dev/docs/clipboard We've reportedly been getting console warnings that we should use @react-native-community/clipboard instead of the RN-core module. Do basically that, except we use its new name, @react-native-clipboard/clipboard; see https://github.com/react-native-clipboard/clipboard/pull/87 Put the types in a .js.flow file, rather than a libdef in `flow-typed/`, inspired by Greg's commit 007dea350. Supersedes: #4502 Co-authored-by: rajprakash00 --- .eslintrc.yaml | 6 +- ios/Podfile.lock | 6 + jest/jestSetup.js | 6 + package.json | 1 + src/RootErrorBoundary.js | 3 +- src/action-sheets/index.js | 3 +- src/webview/handleOutboundEvents.js | 3 +- .../@react-native-clipboard/clipboard.js.flow | 154 ++++++++++++++++++ yarn.lock | 5 + 9 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 types/@react-native-clipboard/clipboard.js.flow diff --git a/.eslintrc.yaml b/.eslintrc.yaml index ee0fe1d5d7c..3629246fb34 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -225,7 +225,11 @@ rules: - devDependencies: ['**/__tests__/**/*.js', tools/**] no-restricted-imports: - error - - patterns: + - paths: + - name: 'react-native' + importNames: ['Clipboard'] + message: 'Use Clipboard from @react-native-clipboard/clipboard instead.' + patterns: - group: ['**/__tests__/**'] - group: ['/react-redux'] message: 'Use our own src/react-redux.js instead.' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 6fadfffaee5..e3b8365a74b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -360,6 +360,8 @@ PODS: - React-Core - RNCAsyncStorage (1.16.1): - React-Core + - RNCClipboard (1.8.5): + - React-Core - RNCMaskedView (0.1.11): - React - RNCPushNotificationIOS (1.10.1): @@ -480,6 +482,7 @@ DEPENDENCIES: - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - rn-fetch-blob (from `../node_modules/rn-fetch-blob`) - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" + - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - "RNCMaskedView (from `../node_modules/@react-native-community/masked-view`)" - "RNCPushNotificationIOS (from `../node_modules/@react-native-community/push-notification-ios`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) @@ -603,6 +606,8 @@ EXTERNAL SOURCES: :path: "../node_modules/rn-fetch-blob" RNCAsyncStorage: :path: "../node_modules/@react-native-async-storage/async-storage" + RNCClipboard: + :path: "../node_modules/@react-native-clipboard/clipboard" RNCMaskedView: :path: "../node_modules/@react-native-community/masked-view" RNCPushNotificationIOS: @@ -680,6 +685,7 @@ SPEC CHECKSUMS: ReactCommon: 8fea6422328e2fc093e25c9fac67adbcf0f04fb4 rn-fetch-blob: f525a73a78df9ed5d35e67ea65e79d53c15255bc RNCAsyncStorage: b49b4e38a1548d03b74b30e558a1d18465b94be7 + RNCClipboard: cc054ad1e8a33d2a74cd13e565588b4ca928d8fd RNCMaskedView: 0e1bc4bfa8365eba5fbbb71e07fbdc0555249489 RNCPushNotificationIOS: 87b8d16d3ede4532745e05b03c42cff33a36cc45 RNDeviceInfo: 4944cf8787b9c5bffaf301fda68cc1a2ec003341 diff --git a/jest/jestSetup.js b/jest/jestSetup.js index e5589b0adb6..1ccf6bcba4b 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -5,6 +5,8 @@ import { polyfillGlobal } from 'react-native/Libraries/Utilities/PolyfillFunctio import { URL, URLSearchParams } from 'react-native-url-polyfill'; // $FlowIgnore[untyped-import] - this is not anywhere near critical import mockAsyncStorage from '@react-native-async-storage/async-storage/jest/async-storage-mock'; +// $FlowIgnore[untyped-import] - this is not anywhere near critical +import mockClipboard from '@react-native-clipboard/clipboard/jest/clipboard-mock'; import { assertUsingModernFakeTimers } from '../src/__tests__/lib/fakeTimers'; @@ -102,6 +104,10 @@ jest.mock('react-native-reanimated', () => { jest.mock('@react-native-async-storage/async-storage', () => mockAsyncStorage); +// As instructed at +// https://github.com/react-native-clipboard/clipboard/tree/v1.9.0#mocking-clipboard +jest.mock('@react-native-clipboard/clipboard', () => mockClipboard); + // Without this, we get lots of these errors on importing the module: // `Invariant Violation: Native module cannot be null.` jest.mock('@react-native-community/push-notification-ios', () => ({ diff --git a/package.json b/package.json index c33bd3768e9..a434a97f1e7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dependencies": { "@expo/react-native-action-sheet": "^3.8.0", "@react-native-async-storage/async-storage": "^1.13.0", + "@react-native-clipboard/clipboard": "^1.8.5", "@react-native-community/cameraroll": "chrisbobbe/react-native-cameraroll#17fa5d8d2", "@react-native-community/masked-view": "^0.1.10", "@react-native-community/netinfo": "6.0.0", diff --git a/src/RootErrorBoundary.js b/src/RootErrorBoundary.js index e35ff2c40eb..993a8ba28ae 100644 --- a/src/RootErrorBoundary.js +++ b/src/RootErrorBoundary.js @@ -1,7 +1,8 @@ /* @flow strict-local */ import React from 'react'; import type { Node } from 'react'; -import { View, Text, Clipboard, TextInput, ScrollView, Button, Platform } from 'react-native'; +import { View, Text, TextInput, ScrollView, Button, Platform } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; import Toast from 'react-native-simple-toast'; // $FlowFixMe[untyped-import] import isEqual from 'lodash.isequal'; diff --git a/src/action-sheets/index.js b/src/action-sheets/index.js index e0199d8d57a..c662cf2bfdb 100644 --- a/src/action-sheets/index.js +++ b/src/action-sheets/index.js @@ -1,5 +1,6 @@ /* @flow strict-local */ -import { Clipboard, Share, Alert } from 'react-native'; +import { Share, Alert } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; import invariant from 'invariant'; import * as resolved_topic from '@zulip/shared/js/resolved_topic'; diff --git a/src/webview/handleOutboundEvents.js b/src/webview/handleOutboundEvents.js index d986d5e9d72..57b95097f28 100644 --- a/src/webview/handleOutboundEvents.js +++ b/src/webview/handleOutboundEvents.js @@ -1,5 +1,6 @@ /* @flow strict-local */ -import { Clipboard, Alert } from 'react-native'; +import { Alert } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; import * as NavigationService from '../nav/NavigationService'; import * as api from '../api'; diff --git a/types/@react-native-clipboard/clipboard.js.flow b/types/@react-native-clipboard/clipboard.js.flow new file mode 100644 index 00000000000..2abec4bcd4b --- /dev/null +++ b/types/@react-native-clipboard/clipboard.js.flow @@ -0,0 +1,154 @@ +/** + * Flowtype definitions for Clipboard + * Generated by Flowgen from a Typescript Definition + * Flowgen v1.11.0 + * + * @flow strict-local + */ + +import type EmitterSubscription from 'react-native/Libraries/vendor/emitter/_EmitterSubscription'; + +/** + * `Clipboard` gives you an interface for setting and getting content from Clipboard on both iOS and Android + */ +declare var Clipboard: { + /** + * Get content of string type, this method returns a `Promise`, so you can use following code to get clipboard content + * ```javascript + * async _getContent() { + * var content = await Clipboard.getString(); + * } + * ``` + */ + getString(): Promise, + + /** + * Get clipboard image as PNG in base64, this method returns a `Promise`, so you can use following code to get clipboard content + * ```javascript + * async _getContent() { + * var content = await Clipboard.getImagePNG(); + * } + * ``` + */ + getImagePNG(): Promise, + + /** + * Get clipboard image as JPG in base64, this method returns a `Promise`, so you can use following code to get clipboard content + * ```javascript + * async _getContent() { + * var content = await Clipboard.getImageJPG(); + * } + * ``` + */ + getImageJPG(): Promise, + + /** + * Set content of base64 image type. You can use following code to set clipboard content + * ```javascript + * _setContent() { + * Clipboard.setImage(...); + * } + * + * iOS only + * ``` + * @param the content to be stored in the clipboard. + */ + setImage(content: string): void, + getImage(): Promise, + + /** + * Set content of string type. You can use following code to set clipboard content + * ```javascript + * _setContent() { + * Clipboard.setString('hello world'); + * } + * ``` + * @param the content to be stored in the clipboard. + */ + setString(content: string): void, + + /** + * Returns whether the clipboard has content or is empty. + * This method returns a `Promise`, so you can use following code to get clipboard content + * ```javascript + * async _hasContent() { + * var hasContent = await Clipboard.hasString(); + * } + * ``` + */ + hasString(): $FlowFixMe, // `any` in TypeScript upstream :( + + /** + * Returns whether the clipboard has an image or is empty. + * This method returns a `Promise`, so you can use following code to check clipboard content + * ```javascript + * async _hasContent() { + * var hasContent = await Clipboard.hasImage(); + * } + * ``` + */ + hasImage(): $FlowFixMe, // `any` in TypeScript upstream :( + + /** + * (IOS Only) + * Returns whether the clipboard has a URL content. Can check + * if there is a URL content in clipboard without triggering PasteBoard notification for iOS 14+ + * This method returns a `Promise`, so you can use following code to check for url content in clipboard. + * ```javascript + * async _hasURL() { + * var hasURL = await Clipboard.hasURL(); + * } + * ``` + */ + hasURL(): $FlowFixMe, // `any` in TypeScript upstream :( + + /** + * (IOS 14+ Only) + * Returns whether the clipboard has a Number(UIPasteboardDetectionPatternNumber) content. Can check + * if there is a Number content in clipboard without triggering PasteBoard notification for iOS 14+ + * This method returns a `Promise`, so you can use following code to check for Number content in clipboard. + * ```javascript + * async _hasNumber() { + * var hasNumber = await Clipboard.hasNumber(); + * } + * ``` + */ + hasNumber(): $FlowFixMe, // `any` in TypeScript upstream :( + + /** + * (IOS 14+ Only) + * Returns whether the clipboard has a WebURL(UIPasteboardDetectionPatternProbableWebURL) content. Can check + * if there is a WebURL content in clipboard without triggering PasteBoard notification for iOS 14+ + * This method returns a `Promise`, so you can use following code to check for WebURL content in clipboard. + * ```javascript + * async _hasWebURL() { + * var hasWebURL = await Clipboard.hasWebURL(); + * } + * ``` + */ + hasWebURL(): $FlowFixMe, // `any` in TypeScript upstream :( + + /** + * (iOS and Android Only) + * Adds a listener to get notifications when the clipboard has changed. + * If this is the first listener, turns on clipboard notifications on the native side. + * It returns EmitterSubscription where you can call "remove" to remove listener + * ```javascript + * const listener = () => console.log("changed!"); + * Clipboard.addListener(listener); + * ``` + */ + addListener(callback: () => void): EmitterSubscription, + + /** + * (iOS and Android Only) + * Removes all previously registered listeners and turns off notifications on the native side. + * ```javascript + * Clipboard.removeAllListeners(); + * ``` + */ + removeAllListeners(): void, + ... +}; + +export default Clipboard; diff --git a/yarn.lock b/yarn.lock index 725c7e60cd1..56c093c3841 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1795,6 +1795,11 @@ dependencies: merge-options "^3.0.4" +"@react-native-clipboard/clipboard@^1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@react-native-clipboard/clipboard/-/clipboard-1.8.5.tgz#b11276e38ef288b0fd70c0a38506e2deecc5fa5a" + integrity sha512-o2RPDwP9JMnLece1Qq6a3Fsz/VxfA9auLckkGOor7WcI82DWaWiJ6Uiyu7H1xpaUyqWc+ypVKRX680GYS36HjA== + "@react-native-community/cameraroll@chrisbobbe/react-native-cameraroll#17fa5d8d2": version "4.0.4" resolved "https://codeload.github.com/chrisbobbe/react-native-cameraroll/tar.gz/17fa5d8d2f4e00ec78304070a0b91292e884b7f5" From 8628bdd0221230e9121ce8212214f7ddea9656bd Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 1 Mar 2022 13:21:23 -0800 Subject: [PATCH 2/9] clipboard types: Fix $FlowFixMes --- .../@react-native-clipboard/clipboard.js.flow | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/types/@react-native-clipboard/clipboard.js.flow b/types/@react-native-clipboard/clipboard.js.flow index 2abec4bcd4b..c38a4ea07b7 100644 --- a/types/@react-native-clipboard/clipboard.js.flow +++ b/types/@react-native-clipboard/clipboard.js.flow @@ -76,7 +76,12 @@ declare var Clipboard: { * } * ``` */ - hasString(): $FlowFixMe, // `any` in TypeScript upstream :( + // In the TS upstream, this is `any`. But reading code in 1.8.5, the + // resolve value is always a boolean: + // [NSNumber numberWithBool: …] + // (NSNumber is a funny way of saying boolean; it's a "boxed value": + // https://clang.llvm.org/docs/ObjectiveCLiterals.html ) + hasString(): Promise, /** * Returns whether the clipboard has an image or is empty. @@ -87,7 +92,12 @@ declare var Clipboard: { * } * ``` */ - hasImage(): $FlowFixMe, // `any` in TypeScript upstream :( + // In the TS upstream, this is `any`. But reading code in 1.8.5, the + // resolve value is always a boolean: + // [NSNumber numberWithBool: …] + // (NSNumber is a funny way of saying boolean; it's a "boxed value": + // https://clang.llvm.org/docs/ObjectiveCLiterals.html ) + hasImage(): Promise, /** * (IOS Only) @@ -100,7 +110,12 @@ declare var Clipboard: { * } * ``` */ - hasURL(): $FlowFixMe, // `any` in TypeScript upstream :( + // In the TS upstream, this is `any`. But reading code in 1.8.5, the + // resolve value is always a boolean: + // [NSNumber numberWithBool: …] + // (NSNumber is a funny way of saying boolean; it's a "boxed value": + // https://clang.llvm.org/docs/ObjectiveCLiterals.html ) + hasURL(): Promise, /** * (IOS 14+ Only) @@ -113,7 +128,12 @@ declare var Clipboard: { * } * ``` */ - hasNumber(): $FlowFixMe, // `any` in TypeScript upstream :( + // In the TS upstream, this is `any`. But reading code in 1.8.5, the + // resolve value is always a boolean: + // [NSNumber numberWithBool: …] + // (NSNumber is a funny way of saying boolean; it's a "boxed value": + // https://clang.llvm.org/docs/ObjectiveCLiterals.html ) + hasNumber(): Promise, /** * (IOS 14+ Only) @@ -126,7 +146,12 @@ declare var Clipboard: { * } * ``` */ - hasWebURL(): $FlowFixMe, // `any` in TypeScript upstream :( + // In the TS upstream, this is `any`. But reading code in 1.8.5, the + // resolve value is always a boolean: + // [NSNumber numberWithBool: …] + // (NSNumber is a funny way of saying boolean; it's a "boxed value": + // https://clang.llvm.org/docs/ObjectiveCLiterals.html ) + hasWebURL(): Promise, /** * (iOS and Android Only) From ad5ee128243525ce5ad01c85d259d6c62ff92172 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 17 Feb 2022 17:15:30 -0800 Subject: [PATCH 3/9] RealmInputScreen [nfc]: Convert to function component --- src/start/RealmInputScreen.js | 119 +++++++++++++++------------------- 1 file changed, 53 insertions(+), 66 deletions(-) diff --git a/src/start/RealmInputScreen.js b/src/start/RealmInputScreen.js index 89cf0faaddb..da40547ca5c 100644 --- a/src/start/RealmInputScreen.js +++ b/src/start/RealmInputScreen.js @@ -1,5 +1,5 @@ /* @flow strict-local */ -import React, { PureComponent } from 'react'; +import React, { useState, useCallback } from 'react'; import type { Node } from 'react'; import { Keyboard } from 'react-native'; @@ -21,12 +21,6 @@ type Props = $ReadOnly<{| route: RouteProp<'realm-input', {| initial: boolean | void |}>, |}>; -type State = {| - realmInputValue: string, - error: string | null, - progress: boolean, -|}; - const urlFromInputValue = (realmInputValue: string): URL | void => { const withScheme = /^https?:\/\//.test(realmInputValue) ? realmInputValue @@ -35,88 +29,81 @@ const urlFromInputValue = (realmInputValue: string): URL | void => { return tryParseUrl(withScheme); }; -export default class RealmInputScreen extends PureComponent { - state: State = { - progress: false, - realmInputValue: '', - error: null, - }; - - tryRealm: () => Promise = async () => { - const { realmInputValue } = this.state; +export default function RealmInputScreen(props: Props): Node { + const [progress, setProgress] = useState(false); + const [realmInputValue, setRealmInputValue] = useState(''); + const [error, setError] = useState(null); + const tryRealm = useCallback(async () => { const parsedRealm = urlFromInputValue(realmInputValue); if (!parsedRealm) { - this.setState({ error: 'Please enter a valid URL' }); + setError('Please enter a valid URL'); return; } if (parsedRealm.username !== '') { - this.setState({ error: 'Please enter the server URL, not your email' }); + setError('Please enter the server URL, not your email'); return; } - this.setState({ - progress: true, - error: null, - }); + setProgress(true); + setError(null); try { const serverSettings: ApiResponseServerSettings = await api.getServerSettings(parsedRealm); NavigationService.dispatch(navigateToAuth(serverSettings)); Keyboard.dismiss(); } catch (errorIllTyped) { const err: mixed = errorIllTyped; // https://github.com/facebook/flow/issues/2470 - this.setState({ error: 'Cannot connect to server' }); + setError('Cannot connect to server'); /* eslint-disable no-console */ console.warn('RealmInputScreen: failed to connect to server:', err); // $FlowFixMe[incompatible-cast]: assuming caught exception was Error console.warn((err: Error).stack); } finally { - this.setState({ progress: false }); + setProgress(false); } - }; + }, [realmInputValue]); - handleRealmChange: string => void = value => this.setState({ realmInputValue: value }); + const handleRealmChange = useCallback(value => { + setRealmInputValue(value); + }, []); - render(): Node { - const { navigation } = this.props; - const { progress, error, realmInputValue } = this.state; + const { navigation } = props; - const styles = { - input: { marginTop: 16, marginBottom: 8 }, - hintText: { paddingLeft: 2, fontSize: 12 }, - button: { marginTop: 8 }, - }; + const styles = { + input: { marginTop: 16, marginBottom: 8 }, + hintText: { paddingLeft: 2, fontSize: 12 }, + button: { marginTop: 8 }, + }; - return ( - - - - {error !== null ? ( - - ) : ( - - )} - - - ); - } + return ( + + + + {error !== null ? ( + + ) : ( + + )} + + + ); } From 9afdc4cc71b4104ef4b9bbcbe4fbace3eebafa08 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 28 Feb 2022 16:53:26 -0800 Subject: [PATCH 4/9] RealmInputScreen [nfc]: Inline a very thin wrapper --- src/start/RealmInputScreen.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/start/RealmInputScreen.js b/src/start/RealmInputScreen.js index da40547ca5c..82065157f0b 100644 --- a/src/start/RealmInputScreen.js +++ b/src/start/RealmInputScreen.js @@ -63,10 +63,6 @@ export default function RealmInputScreen(props: Props): Node { } }, [realmInputValue]); - const handleRealmChange = useCallback(value => { - setRealmInputValue(value); - }, []); - const { navigation } = props; const styles = { @@ -88,7 +84,7 @@ export default function RealmInputScreen(props: Props): Node { From 1a115f64633acc665e2b68ea39050bbc78984856 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 17 Feb 2022 17:17:12 -0800 Subject: [PATCH 5/9] RealmInputScreen [nfc]: Move variable declaration to top of block --- src/start/RealmInputScreen.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/start/RealmInputScreen.js b/src/start/RealmInputScreen.js index 82065157f0b..dc8a0a55c12 100644 --- a/src/start/RealmInputScreen.js +++ b/src/start/RealmInputScreen.js @@ -30,6 +30,8 @@ const urlFromInputValue = (realmInputValue: string): URL | void => { }; export default function RealmInputScreen(props: Props): Node { + const { navigation } = props; + const [progress, setProgress] = useState(false); const [realmInputValue, setRealmInputValue] = useState(''); const [error, setError] = useState(null); @@ -63,8 +65,6 @@ export default function RealmInputScreen(props: Props): Node { } }, [realmInputValue]); - const { navigation } = props; - const styles = { input: { marginTop: 16, marginBottom: 8 }, hintText: { paddingLeft: 2, fontSize: 12 }, From 3f235ef9dc5a74911899e80ea3062d038c44d013 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 28 Feb 2022 16:50:12 -0800 Subject: [PATCH 6/9] SmartUrlInput [nfc]: Make controlled by caller --- src/common/SmartUrlInput.js | 17 ++++------------- src/start/RealmInputScreen.js | 5 +++++ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/common/SmartUrlInput.js b/src/common/SmartUrlInput.js index daab8bbf989..4653c6b4ac3 100644 --- a/src/common/SmartUrlInput.js +++ b/src/common/SmartUrlInput.js @@ -1,5 +1,5 @@ /* @flow strict-local */ -import React, { useState, useRef, useCallback, useContext } from 'react'; +import React, { useRef, useCallback, useContext } from 'react'; import type { Node } from 'react'; import { TextInput, View } from 'react-native'; import { useFocusEffect } from '@react-navigation/native'; @@ -28,12 +28,13 @@ type Props = $ReadOnly<{| style?: ViewStyleProp, onChangeText: (value: string) => void, + value: string, onSubmitEditing: () => Promise, enablesReturnKeyAutomatically: boolean, |}>; export default function SmartUrlInput(props: Props): Node { - const { style, onChangeText, onSubmitEditing, enablesReturnKeyAutomatically } = props; + const { style, onChangeText, value, onSubmitEditing, enablesReturnKeyAutomatically } = props; // We should replace the fixme with // `React$ElementRef` when we can. Currently, that @@ -41,8 +42,6 @@ export default function SmartUrlInput(props: Props): Node { // this is probably down to bugs in Flow's special support for React. const textInputRef = useRef<$FlowFixMe>(); - const [value, setValue] = useState(''); - const themeContext = useContext(ThemeContext); // When the route is focused in the navigation, focus the input. @@ -65,14 +64,6 @@ export default function SmartUrlInput(props: Props): Node { }, []), ); - const handleChange = useCallback( - (_value: string) => { - setValue(_value); - onChangeText(_value); - }, - [onChangeText], - ); - return ( (false); + + // Prepopulate with "https://"; not everyone has memorized that sequence + // of characters. const [realmInputValue, setRealmInputValue] = useState(''); + const [error, setError] = useState(null); const tryRealm = useCallback(async () => { @@ -85,6 +89,7 @@ export default function RealmInputScreen(props: Props): Node { style={styles.input} navigation={navigation} onChangeText={setRealmInputValue} + value={realmInputValue} onSubmitEditing={tryRealm} enablesReturnKeyAutomatically /> From b8dda3572ba1cef6d52019d4d55f159c896c5e5b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 17 Feb 2022 16:37:16 -0800 Subject: [PATCH 7/9] clipboard: Add useClipboardHasURL hook --- src/@react-native-clipboard/clipboard.js | 74 ++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/@react-native-clipboard/clipboard.js diff --git a/src/@react-native-clipboard/clipboard.js b/src/@react-native-clipboard/clipboard.js new file mode 100644 index 00000000000..c8fb748f88a --- /dev/null +++ b/src/@react-native-clipboard/clipboard.js @@ -0,0 +1,74 @@ +/** + * Helpers for @react-native-clipboard/clipboard + * + * @flow strict-local + */ + +import { useState, useCallback, useEffect } from 'react'; +import { AppState, Platform } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; + +import * as logging from '../utils/logging'; +import { tryParseUrl } from '../utils/url'; + +/** + * A Hook for the current value of Clipboard.hasURL, when known. + * + * https://github.com/react-native-clipboard/clipboard#hasurl + * + * With a hack to simulate Clipboard.hasURL on iOS <14, and on Android. + * + * Returns the payload of the most recently settled Clipboard.hasURL() + * Promise; otherwise `null` if that Promise rejected, or if no such Promise + * has settled. + * + * Re-queries when clipboard value changes, and when app state changes to + * "active". + * + * Subject to subtle races. Don't use for anything critical, and do a + * sanity-check in clipboard reads informed by the result of this (e.g., + * check the retrieved string with `tryParseUrl`). + */ +export function useClipboardHasURL(): boolean | null { + const [result, setResult] = useState(null); + + const getAndSetResult = useCallback(async () => { + try { + // TODO(ios-14): Simplify conditional and jsdoc. + if (Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 14) { + setResult(await Clipboard.hasURL()); + } else { + // Hack: Simulate Clipboard.hasURL + setResult(!!tryParseUrl(await Clipboard.getString())); + } + } catch (e) { + logging.error(e); + setResult(null); + } + }, []); + + useEffect(() => { + getAndSetResult(); + + const clipboardListener = Clipboard.addListener(() => { + getAndSetResult(); + }); + + const appStateChangeListener = AppState.addEventListener('change', s => { + if (s === 'active') { + getAndSetResult(); + } + }); + + return () => { + clipboardListener.remove(); + AppState.removeEventListener('change', appStateChangeListener); + }; + }, [getAndSetResult]); + + return result; +} + +// We probably don't want a useClipboardHasWebURL. The implementation of +// Clipboard.hasWebURL on iOS is such that it matches when the copied string +// *has* a URL, not when it *is* a URL. From d63ea54cd508d61b7d613b33e34ca7a27324ba0c Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 28 Feb 2022 18:22:21 -0800 Subject: [PATCH 8/9] RealmInputScreen [nfc]: Have `tryRealm` take an arg for what realm to try --- src/common/SmartUrlInput.js | 2 +- src/start/RealmInputScreen.js | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/common/SmartUrlInput.js b/src/common/SmartUrlInput.js index 4653c6b4ac3..6968f25b5fb 100644 --- a/src/common/SmartUrlInput.js +++ b/src/common/SmartUrlInput.js @@ -29,7 +29,7 @@ type Props = $ReadOnly<{| style?: ViewStyleProp, onChangeText: (value: string) => void, value: string, - onSubmitEditing: () => Promise, + onSubmitEditing: () => void, enablesReturnKeyAutomatically: boolean, |}>; diff --git a/src/start/RealmInputScreen.js b/src/start/RealmInputScreen.js index 7f496f7a83f..44a650c175c 100644 --- a/src/start/RealmInputScreen.js +++ b/src/start/RealmInputScreen.js @@ -40,8 +40,8 @@ export default function RealmInputScreen(props: Props): Node { const [error, setError] = useState(null); - const tryRealm = useCallback(async () => { - const parsedRealm = urlFromInputValue(realmInputValue); + const tryRealm = useCallback(async unparsedUrl => { + const parsedRealm = urlFromInputValue(unparsedUrl); if (!parsedRealm) { setError('Please enter a valid URL'); return; @@ -67,7 +67,11 @@ export default function RealmInputScreen(props: Props): Node { } finally { setProgress(false); } - }, [realmInputValue]); + }, []); + + const handleInputSubmit = useCallback(() => { + tryRealm(realmInputValue); + }, [tryRealm, realmInputValue]); const styles = { input: { marginTop: 16, marginBottom: 8 }, @@ -90,7 +94,7 @@ export default function RealmInputScreen(props: Props): Node { navigation={navigation} onChangeText={setRealmInputValue} value={realmInputValue} - onSubmitEditing={tryRealm} + onSubmitEditing={handleInputSubmit} enablesReturnKeyAutomatically /> {error !== null ? ( @@ -102,7 +106,7 @@ export default function RealmInputScreen(props: Props): Node { style={styles.button} text="Enter" progress={progress} - onPress={tryRealm} + onPress={handleInputSubmit} disabled={urlFromInputValue(realmInputValue) === undefined} /> From bca7d0cdd6961fcab49bdabf2a46ec3ca39a766d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 25 Feb 2022 17:35:36 -0800 Subject: [PATCH 9/9] RealmInputScreen: Offer nicer experience for copied URL Related: #5228 --- src/start/RealmInputScreen.js | 33 ++++++++++++++++++++++++++++ static/translations/messages_en.json | 1 + 2 files changed, 34 insertions(+) diff --git a/src/start/RealmInputScreen.js b/src/start/RealmInputScreen.js index 44a650c175c..8698ed2a548 100644 --- a/src/start/RealmInputScreen.js +++ b/src/start/RealmInputScreen.js @@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react'; import type { Node } from 'react'; import { Keyboard } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; import type { RouteProp } from '../react-navigation'; import type { AppNavigationProp } from '../nav/AppNavigator'; @@ -15,6 +16,7 @@ import ZulipButton from '../common/ZulipButton'; import { tryParseUrl } from '../utils/url'; import * as api from '../api'; import { navigateToAuth } from '../actions'; +import { useClipboardHasURL } from '../@react-native-clipboard/clipboard'; type Props = $ReadOnly<{| navigation: AppNavigationProp<'realm-input'>, @@ -79,6 +81,24 @@ export default function RealmInputScreen(props: Props): Node { button: { marginTop: 8 }, }; + const tryCopiedUrl = useCallback(async () => { + // The copied string might not be a valid realm URL: + // - It might not be a URL because useClipboardHasURL is subject to + // races (and Clipboard.getString is itself async). + // - It might not be a valid Zulip realm that the client can connect to. + // + // So… + const url = await Clipboard.getString(); + + // …let the user see what string is being tried and edit it if it fails… + setRealmInputValue(url); + + // …and run it through our usual validation. + await tryRealm(url); + }, [tryRealm]); + + const clipboardHasURL = useClipboardHasURL(); + return ( + {clipboardHasURL === true && ( + // Recognize when the user has copied a URL, and let them use it + // without making them enter it into the input. + // + // TODO(?): Instead, use a FAB that persists while + // clipboardHasURL !== true && !progress + + )} ); } diff --git a/static/translations/messages_en.json b/static/translations/messages_en.json index 2c70cfb5731..2a2d51a2a32 100644 --- a/static/translations/messages_en.json +++ b/static/translations/messages_en.json @@ -48,6 +48,7 @@ "Welcome": "Welcome", "Enter your Zulip server URL:": "Enter your Zulip server URL:", "e.g. zulip.example.com": "e.g. zulip.example.com", + "Use copied URL": "Use copied URL", "Subscriptions": "Subscriptions", "Search": "Search", "Log in": "Log in",