diff --git a/src/boot/store.js b/src/boot/store.js index ccbc6f0ffe0..69ded1cf663 100644 --- a/src/boot/store.js +++ b/src/boot/store.js @@ -283,6 +283,14 @@ const migrations: {| [string]: (GlobalState) => GlobalState |} = { accounts: state.accounts.filter(a => a.email !== ''), }), + '28': state => ({ + ...state, + settings: { + ...state.settings, + browser: 'default', + }, + }), + // TIP: When adding a migration, consider just using `dropCache`. }; diff --git a/src/common/Icons.js b/src/common/Icons.js index 5941a686a03..aabee1b1102 100644 --- a/src/common/Icons.js +++ b/src/common/Icons.js @@ -51,6 +51,7 @@ const makeIcon = ( return ; }; +export const IconBrowser = makeIcon(Feather, 'chrome'); export const IconInbox = makeIcon(Feather, 'inbox'); export const IconMention = makeIcon(Feather, 'at-sign'); export const IconSearch = makeIcon(Feather, 'search'); diff --git a/src/common/WebLink.js b/src/common/WebLink.js index 951046910e3..f1e0589f2d6 100644 --- a/src/common/WebLink.js +++ b/src/common/WebLink.js @@ -3,7 +3,7 @@ import React from 'react'; import Label from './Label'; -import openLink from '../utils/openLink'; +import { openLinkEmbedded } from '../utils/openLink'; import { BRAND_COLOR, createStyleSheet } from '../styles'; type Props = $ReadOnly<{| @@ -29,7 +29,7 @@ export default function WebLink(props: Props) { style={componentStyles.link} text={props.label} onPress={() => { - openLink(props.url.toString()); + openLinkEmbedded(props.url.toString()); }} /> ); diff --git a/src/lightbox/LightboxActionSheet.js b/src/lightbox/LightboxActionSheet.js index b1fb4a2710d..1d4252abe67 100644 --- a/src/lightbox/LightboxActionSheet.js +++ b/src/lightbox/LightboxActionSheet.js @@ -5,7 +5,7 @@ import share from './share'; import shareImage from './shareImage'; import { showToast } from '../utils/info'; import * as api from '../api'; -import openLink from '../utils/openLink'; +import { openLinkEmbedded } from '../utils/openLink'; type DownloadImageType = {| src: string, @@ -37,7 +37,7 @@ const tryToDownloadImage = async ({ src, auth }: DownloadImageType) => { const tempUrl = await api.tryGetFileTemporaryUrl(src, auth); if (tempUrl === null) { showToast('Please download the image from your browser'); - openLink(new URL(src, auth.realm).toString()); + openLinkEmbedded(new URL(src, auth.realm).toString()); return; } diff --git a/src/lightbox/shareImage.js b/src/lightbox/shareImage.js index a0587363739..3a1c735f4f2 100644 --- a/src/lightbox/shareImage.js +++ b/src/lightbox/shareImage.js @@ -6,7 +6,7 @@ import type { Auth } from '../types'; import ShareFileAndroid from '../nativeModules/ShareFileAndroid'; import { showToast } from '../utils/info'; import * as api from '../api'; -import openLink from '../utils/openLink'; +import { openLinkEmbedded } from '../utils/openLink'; import * as logging from '../utils/logging'; export default async (url: string, auth: Auth) => { @@ -14,7 +14,7 @@ export default async (url: string, auth: Auth) => { if (tempUrl === null) { showToast('Please share the image from your browser'); - openLink(new URL(url, auth.realm).toString()); + openLinkEmbedded(new URL(url, auth.realm).toString()); return; } diff --git a/src/message/messagesActions.js b/src/message/messagesActions.js index 5012e875ec7..7daaf5f0783 100644 --- a/src/message/messagesActions.js +++ b/src/message/messagesActions.js @@ -3,7 +3,7 @@ import * as NavigationService from '../nav/NavigationService'; import type { Narrow, Dispatch, GetState } from '../types'; import { getAuth } from '../selectors'; import { getMessageIdFromLink, getNarrowFromLink } from '../utils/internalLinks'; -import openLink from '../utils/openLink'; +import { openLinkWithUserPreference } from '../utils/openLink'; import { navigateToChat } from '../nav/navActions'; import { FIRST_UNREAD_ANCHOR } from '../anchor'; import { getStreamsById } from '../subscriptions/subscriptionSelectors'; @@ -35,10 +35,10 @@ export const messageLinkPress = (href: string) => async ( const anchor = getMessageIdFromLink(href, auth.realm); dispatch(doNarrow(narrow, anchor)); } else if (!isUrlOnRealm(href, auth.realm)) { - openLink(href); + openLinkWithUserPreference(href, getState); } else { const url = (await api.tryGetFileTemporaryUrl(href, auth)) ?? new URL(href, auth.realm).toString(); - openLink(url); + openLinkWithUserPreference(url, getState); } }; diff --git a/src/reduxTypes.js b/src/reduxTypes.js index a73b106a048..7ce5f0f87c6 100644 --- a/src/reduxTypes.js +++ b/src/reduxTypes.js @@ -263,6 +263,21 @@ export type RealmState = {| // https://github.com/zulip/zulip-mobile/issues/4009#issuecomment-619280681. export type ThemeName = 'default' | 'night'; +/** + * The values for this mean: + * + * * embedded: The in-app browser + * * external: The user's default browser app + * * default: 'external' on iOS, 'embedded' on Android + * + * Use the `shouldUseInAppBrowser` function from src/utils/openLink.js in order to + * parse this. + * + * See https://chat.zulip.org/#narrow/stream/48-mobile/topic/in-app.20browser + * for the reasoning behind these options. + */ +export type BrowserPreference = 'embedded' | 'external' | 'default'; + export type SettingsState = {| locale: string, theme: ThemeName, @@ -270,6 +285,7 @@ export type SettingsState = {| onlineNotification: boolean, experimentalFeaturesEnabled: boolean, streamNotification: boolean, + browser: BrowserPreference, |}; export type StreamsState = Stream[]; diff --git a/src/settings/LegalScreen.js b/src/settings/LegalScreen.js index d9ec31aa00b..76a176ac6a5 100644 --- a/src/settings/LegalScreen.js +++ b/src/settings/LegalScreen.js @@ -7,7 +7,7 @@ import type { AppNavigationProp } from '../nav/AppNavigator'; import type { Dispatch } from '../types'; import { connect } from '../react-redux'; import { Screen, OptionButton } from '../common'; -import openLink from '../utils/openLink'; +import { openLinkEmbedded } from '../utils/openLink'; import { getCurrentRealm } from '../selectors'; type Props = $ReadOnly<{| @@ -21,12 +21,12 @@ type Props = $ReadOnly<{| class LegalScreen extends PureComponent { openTermsOfService = () => { const { realm } = this.props; - openLink(new URL('/terms/?nav=no', realm).toString()); + openLinkEmbedded(new URL('/terms/?nav=no', realm).toString()); }; openPrivacyPolicy = () => { const { realm } = this.props; - openLink(new URL('/privacy/?nav=no', realm).toString()); + openLinkEmbedded(new URL('/privacy/?nav=no', realm).toString()); }; render() { diff --git a/src/settings/SettingsScreen.js b/src/settings/SettingsScreen.js index 6c5f7bf49ac..ac441322d4d 100644 --- a/src/settings/SettingsScreen.js +++ b/src/settings/SettingsScreen.js @@ -6,12 +6,13 @@ import { ScrollView } from 'react-native'; import type { RouteProp } from '../react-navigation'; import type { MainTabsNavigationProp } from '../main/MainTabsScreen'; import * as NavigationService from '../nav/NavigationService'; -import type { Dispatch } from '../types'; +import type { Dispatch, BrowserPreference } from '../types'; import { createStyleSheet } from '../styles'; import { connect } from '../react-redux'; import { getSettings } from '../selectors'; import { OptionButton, OptionRow } from '../common'; import { + IconBrowser, IconDiagnostics, IconNotifications, IconNight, @@ -25,6 +26,7 @@ import { navigateToDiagnostics, navigateToLegal, } from '../actions'; +import { shouldUseInAppBrowser } from '../utils/openLink'; const styles = createStyleSheet({ optionWrapper: { @@ -37,6 +39,7 @@ type Props = $ReadOnly<{| route: RouteProp<'settings', void>, theme: string, + browser: BrowserPreference, dispatch: Dispatch, |}>; @@ -47,7 +50,7 @@ class SettingsScreen extends PureComponent { }; render() { - const { theme } = this.props; + const { dispatch, theme, browser } = this.props; return ( @@ -57,6 +60,14 @@ class SettingsScreen extends PureComponent { value={theme === 'night'} onValueChange={this.handleThemeChange} /> + { + dispatch(settingsChange({ browser: value ? 'embedded' : 'external' })); + }} + /> { export default connect(state => ({ theme: getSettings(state).theme, + browser: getSettings(state).browser, }))(SettingsScreen); diff --git a/src/settings/settingsReducer.js b/src/settings/settingsReducer.js index 6e51ab66cec..0b292659310 100644 --- a/src/settings/settingsReducer.js +++ b/src/settings/settingsReducer.js @@ -14,6 +14,7 @@ const initialState: SettingsState = { onlineNotification: true, experimentalFeaturesEnabled: false, streamNotification: false, + browser: 'default', }; export default (state: SettingsState = initialState, action: Action): SettingsState => { diff --git a/src/start/AuthScreen.js b/src/start/AuthScreen.js index 2e99d21c0ec..eb55689ba70 100644 --- a/src/start/AuthScreen.js +++ b/src/start/AuthScreen.js @@ -32,7 +32,7 @@ import { encodeParamsForUrl } from '../utils/url'; import * as webAuth from './webAuth'; import { loginSuccess, navigateToDevAuth, navigateToPasswordAuth } from '../actions'; import IosCompliantAppleAuthButton from './IosCompliantAppleAuthButton'; -import openLink from '../utils/openLink'; +import { openLinkEmbedded } from '../utils/openLink'; /** * Describes a method for authenticating to the server. @@ -270,7 +270,7 @@ class AuthScreen extends PureComponent { id_token: credential.identityToken, }); - openLink(new URL(`/complete/apple/?${params}`, this.props.realm).toString()); + openLinkEmbedded(new URL(`/complete/apple/?${params}`, this.props.realm).toString()); // Currently, the rest is handled with the `zulip://` redirect, // same as in the web flow. diff --git a/src/start/CompatibilityScreen.js b/src/start/CompatibilityScreen.js index ebb6aa7edae..8395e1ade00 100644 --- a/src/start/CompatibilityScreen.js +++ b/src/start/CompatibilityScreen.js @@ -1,7 +1,8 @@ /* @flow strict-local */ import React, { PureComponent } from 'react'; -import { Image, Text, View, Platform, Linking } from 'react-native'; +import { Image, Text, View, Platform } from 'react-native'; +import { openLinkExternal } from '../utils/openLink'; import { Touchable } from '../common'; import { BRAND_COLOR, createStyleSheet } from '../styles'; import appStoreBadgePNG from '../../static/img/app-store-badge.png'; @@ -51,7 +52,7 @@ export default class CompatibilityScreen extends PureComponent<{||}> { : 'https://play.google.com/store/apps/details?id=com.zulipmobile'; openStoreURL = () => { - Linking.openURL(this.storeURL); + openLinkExternal(this.storeURL); }; render() { diff --git a/src/start/webAuth.js b/src/start/webAuth.js index d818708675b..c308f6c3521 100644 --- a/src/start/webAuth.js +++ b/src/start/webAuth.js @@ -3,7 +3,7 @@ import { NativeModules, Platform } from 'react-native'; import SafariView from 'react-native-safari-view'; import type { Auth } from '../types'; -import openLink from '../utils/openLink'; +import { openLinkEmbedded } from '../utils/openLink'; import { tryParseUrl } from '../utils/url'; import { base64ToHex, hexToAscii, xorHexStrings } from '../utils/encoding'; @@ -48,7 +48,7 @@ export const generateRandomToken = async (): Promise => { export const generateOtp = async (): Promise => generateRandomToken(); export const openBrowser = (url: string, otp: string) => { - openLink(`${url}?mobile_flow_otp=${otp}`); + openLinkEmbedded(`${url}?mobile_flow_otp=${otp}`); }; export const closeBrowser = () => { diff --git a/src/utils/openLink.js b/src/utils/openLink.js index ee9144dbd03..544ab09a93b 100644 --- a/src/utils/openLink.js +++ b/src/utils/openLink.js @@ -1,11 +1,38 @@ /* @flow strict-local */ -import { NativeModules, Platform } from 'react-native'; +import { NativeModules, Platform, Linking } from 'react-native'; import SafariView from 'react-native-safari-view'; -export default (url: string): void => { +import type { BrowserPreference, GetState } from '../types'; +import { getSettings } from '../selectors'; + +export function openLinkEmbedded(url: string): void { if (Platform.OS === 'ios') { SafariView.show({ url: encodeURI(url) }); } else { NativeModules.CustomTabsAndroid.openURL(url); } -}; +} + +export function openLinkExternal(url: string): void { + Linking.openURL(url); +} + +export function shouldUseInAppBrowser(browser: BrowserPreference): boolean { + if (browser === 'default') { + return Platform.OS === 'android'; + } else { + return browser === 'embedded'; + } +} + +// TODO: We may want to turn this into a thunk action creator. +// See https://github.com/zulip/zulip-mobile/pull/4679#discussion_r625991786 +export function openLinkWithUserPreference(url: string, getState: GetState): void { + const state = getState(); + const browser = getSettings(state).browser; + if (shouldUseInAppBrowser(browser)) { + openLinkEmbedded(url); + } else { + openLinkExternal(url); + } +} diff --git a/static/translations/messages_en.json b/static/translations/messages_en.json index 334bf1d9319..9738a7b70ea 100644 --- a/static/translations/messages_en.json +++ b/static/translations/messages_en.json @@ -61,6 +61,7 @@ "No Internet connection": "No Internet connection", "Settings": "Settings", "Night mode": "Night mode", + "Open links with in-app browser": "Open links with in-app browser", "Language": "Language", "Arabic": "Arabic", "Bokmål": "Bokmål",