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/@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. 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/common/SmartUrlInput.js b/src/common/SmartUrlInput.js index daab8bbf989..6968f25b5fb 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, - onSubmitEditing: () => Promise, + value: string, + onSubmitEditing: () => void, 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 ( , 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 +31,117 @@ const urlFromInputValue = (realmInputValue: string): URL | void => { return tryParseUrl(withScheme); }; -export default class RealmInputScreen extends PureComponent { - state: State = { - progress: false, - realmInputValue: '', - error: null, - }; +export default function RealmInputScreen(props: Props): Node { + const { navigation } = props; + + const [progress, setProgress] = useState(false); - tryRealm: () => Promise = async () => { - const { realmInputValue } = this.state; + // Prepopulate with "https://"; not everyone has memorized that sequence + // of characters. + const [realmInputValue, setRealmInputValue] = useState(''); - const parsedRealm = urlFromInputValue(realmInputValue); + const [error, setError] = useState(null); + + const tryRealm = useCallback(async unparsedUrl => { + const parsedRealm = urlFromInputValue(unparsedUrl); 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); } + }, []); + + const handleInputSubmit = useCallback(() => { + tryRealm(realmInputValue); + }, [tryRealm, realmInputValue]); + + const styles = { + input: { marginTop: 16, marginBottom: 8 }, + hintText: { paddingLeft: 2, fontSize: 12 }, + button: { marginTop: 8 }, }; - handleRealmChange: string => void = value => this.setState({ realmInputValue: value }); - - render(): Node { - const { navigation } = this.props; - const { progress, error, realmInputValue } = this.state; - - const styles = { - input: { marginTop: 16, marginBottom: 8 }, - hintText: { paddingLeft: 2, fontSize: 12 }, - button: { marginTop: 8 }, - }; - - return ( - - - - {error !== null ? ( - - ) : ( - - )} + 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 ( + + + + {error !== null ? ( + + ) : ( + + )} + + {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/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/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", diff --git a/types/@react-native-clipboard/clipboard.js.flow b/types/@react-native-clipboard/clipboard.js.flow new file mode 100644 index 00000000000..c38a4ea07b7 --- /dev/null +++ b/types/@react-native-clipboard/clipboard.js.flow @@ -0,0 +1,179 @@ +/** + * 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(); + * } + * ``` + */ + // 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. + * This method returns a `Promise`, so you can use following code to check clipboard content + * ```javascript + * async _hasContent() { + * var hasContent = await Clipboard.hasImage(); + * } + * ``` + */ + // 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) + * 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(); + * } + * ``` + */ + // 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) + * 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(); + * } + * ``` + */ + // 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) + * 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(); + * } + * ``` + */ + // 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) + * 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"