about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/state/modals/index.tsx6
-rw-r--r--src/state/persisted/schema.ts2
-rw-r--r--src/state/preferences/in-app-browser.tsx79
-rw-r--r--src/state/preferences/index.tsx5
-rw-r--r--src/view/com/modals/InAppBrowserConsent.tsx102
-rw-r--r--src/view/com/modals/LinkWarning.tsx6
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/util/Link.tsx12
-rw-r--r--src/view/screens/Settings.tsx18
9 files changed, 228 insertions, 6 deletions
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 8c32c472a..45856e108 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -187,6 +187,11 @@ export interface EmbedConsentModal {
   onAccept: () => void
 }
 
+export interface InAppBrowserConsentModal {
+  name: 'in-app-browser-consent'
+  href: string
+}
+
 export type Modal =
   // Account
   | AddAppPasswordModal
@@ -231,6 +236,7 @@ export type Modal =
   | ConfirmModal
   | LinkWarningModal
   | EmbedConsentModal
+  | InAppBrowserConsentModal
 
 const ModalContext = React.createContext<{
   isModalActive: boolean
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 6a26cedae..a6f2ea06a 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -53,6 +53,7 @@ export const schema = z.object({
     step: z.string(),
   }),
   hiddenPosts: z.array(z.string()).optional(), // should move to server
+  useInAppBrowser: z.boolean().optional(),
 })
 export type Schema = z.infer<typeof schema>
 
@@ -84,4 +85,5 @@ export const defaults: Schema = {
     step: 'Home',
   },
   hiddenPosts: [],
+  useInAppBrowser: undefined,
 }
diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx
new file mode 100644
index 000000000..628663af4
--- /dev/null
+++ b/src/state/preferences/in-app-browser.tsx
@@ -0,0 +1,79 @@
+import React from 'react'
+import * as persisted from '#/state/persisted'
+import {Linking} from 'react-native'
+import * as WebBrowser from 'expo-web-browser'
+import {isNative} from '#/platform/detection'
+import {useModalControls} from '../modals'
+
+type StateContext = persisted.Schema['useInAppBrowser']
+type SetContext = (v: persisted.Schema['useInAppBrowser']) => void
+
+const stateContext = React.createContext<StateContext>(
+  persisted.defaults.useInAppBrowser,
+)
+const setContext = React.createContext<SetContext>(
+  (_: persisted.Schema['useInAppBrowser']) => {},
+)
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const [state, setState] = React.useState(persisted.get('useInAppBrowser'))
+
+  const setStateWrapped = React.useCallback(
+    (inAppBrowser: persisted.Schema['useInAppBrowser']) => {
+      setState(inAppBrowser)
+      persisted.write('useInAppBrowser', inAppBrowser)
+    },
+    [setState],
+  )
+
+  React.useEffect(() => {
+    return persisted.onUpdate(() => {
+      setState(persisted.get('useInAppBrowser'))
+    })
+  }, [setStateWrapped])
+
+  return (
+    <stateContext.Provider value={state}>
+      <setContext.Provider value={setStateWrapped}>
+        {children}
+      </setContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export function useInAppBrowser() {
+  return React.useContext(stateContext)
+}
+
+export function useSetInAppBrowser() {
+  return React.useContext(setContext)
+}
+
+export function useOpenLink() {
+  const {openModal} = useModalControls()
+  const enabled = useInAppBrowser()
+
+  const openLink = React.useCallback(
+    (url: string, override?: boolean) => {
+      if (isNative && !url.startsWith('mailto:')) {
+        if (override === undefined && enabled === undefined) {
+          openModal({
+            name: 'in-app-browser-consent',
+            href: url,
+          })
+          return
+        } else if (override ?? enabled) {
+          WebBrowser.openBrowserAsync(url, {
+            presentationStyle:
+              WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
+          })
+          return
+        }
+      }
+      Linking.openURL(url)
+    },
+    [enabled, openModal],
+  )
+
+  return openLink
+}
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index cc2d9244c..a442b763a 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -3,6 +3,7 @@ import {Provider as LanguagesProvider} from './languages'
 import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
 import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
 import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
+import {Provider as InAppBrowserProvider} from './in-app-browser'
 
 export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
 export {
@@ -20,7 +21,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     <LanguagesProvider>
       <AltTextRequiredProvider>
         <ExternalEmbedsProvider>
-          <HiddenPostsProvider>{children}</HiddenPostsProvider>
+          <HiddenPostsProvider>
+            <InAppBrowserProvider>{children}</InAppBrowserProvider>
+          </HiddenPostsProvider>
         </ExternalEmbedsProvider>
       </AltTextRequiredProvider>
     </LanguagesProvider>
diff --git a/src/view/com/modals/InAppBrowserConsent.tsx b/src/view/com/modals/InAppBrowserConsent.tsx
new file mode 100644
index 000000000..86bb46ca8
--- /dev/null
+++ b/src/view/com/modals/InAppBrowserConsent.tsx
@@ -0,0 +1,102 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+
+import {s} from 'lib/styles'
+import {Text} from '../util/text/Text'
+import {Button} from '../util/forms/Button'
+import {ScrollView} from './util'
+import {usePalette} from 'lib/hooks/usePalette'
+
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {
+  useOpenLink,
+  useSetInAppBrowser,
+} from '#/state/preferences/in-app-browser'
+
+export const snapPoints = [350]
+
+export function Component({href}: {href: string}) {
+  const pal = usePalette('default')
+  const {closeModal} = useModalControls()
+  const {_} = useLingui()
+  const setInAppBrowser = useSetInAppBrowser()
+  const openLink = useOpenLink()
+
+  const onUseIAB = React.useCallback(() => {
+    setInAppBrowser(true)
+    closeModal()
+    openLink(href, true)
+  }, [closeModal, setInAppBrowser, href, openLink])
+
+  const onUseLinking = React.useCallback(() => {
+    setInAppBrowser(false)
+    closeModal()
+    openLink(href, false)
+  }, [closeModal, setInAppBrowser, href, openLink])
+
+  return (
+    <ScrollView
+      testID="inAppBrowserConsentModal"
+      style={[s.flex1, pal.view, {paddingHorizontal: 20, paddingTop: 10}]}>
+      <Text style={[pal.text, styles.title]}>
+        <Trans>How should we open this link?</Trans>
+      </Text>
+      <Text style={pal.text}>
+        <Trans>
+          Your choice will be saved, but can be changed later in settings.
+        </Trans>
+      </Text>
+      <View style={[styles.btnContainer]}>
+        <Button
+          testID="confirmBtn"
+          type="inverted"
+          onPress={onUseIAB}
+          accessibilityLabel={_(msg`Use in-app browser`)}
+          accessibilityHint=""
+          label={_(msg`Use in-app browser`)}
+          labelContainerStyle={{justifyContent: 'center', padding: 8}}
+          labelStyle={[s.f18]}
+        />
+        <Button
+          testID="confirmBtn"
+          type="inverted"
+          onPress={onUseLinking}
+          accessibilityLabel={_(msg`Use my default browser`)}
+          accessibilityHint=""
+          label={_(msg`Use my default browser`)}
+          labelContainerStyle={{justifyContent: 'center', padding: 8}}
+          labelStyle={[s.f18]}
+        />
+        <Button
+          testID="cancelBtn"
+          type="default"
+          onPress={() => {
+            closeModal()
+          }}
+          accessibilityLabel={_(msg`Cancel`)}
+          accessibilityHint=""
+          label="Cancel"
+          labelContainerStyle={{justifyContent: 'center', padding: 8}}
+          labelStyle={[s.f18]}
+        />
+      </View>
+    </ScrollView>
+  )
+}
+
+const styles = StyleSheet.create({
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+    marginBottom: 12,
+  },
+  btnContainer: {
+    marginTop: 20,
+    flexDirection: 'column',
+    justifyContent: 'center',
+    rowGap: 10,
+  },
+})
diff --git a/src/view/com/modals/LinkWarning.tsx b/src/view/com/modals/LinkWarning.tsx
index 39e6cc3e6..81fdc7285 100644
--- a/src/view/com/modals/LinkWarning.tsx
+++ b/src/view/com/modals/LinkWarning.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {Linking, SafeAreaView, StyleSheet, View} from 'react-native'
+import {SafeAreaView, StyleSheet, View} from 'react-native'
 import {ScrollView} from './util'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
@@ -12,6 +12,7 @@ import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
+import {useOpenLink} from '#/state/preferences/in-app-browser'
 
 export const snapPoints = ['50%']
 
@@ -21,10 +22,11 @@ export function Component({text, href}: {text: string; href: string}) {
   const {isMobile} = useWebMediaQueries()
   const {_} = useLingui()
   const potentiallyMisleading = isPossiblyAUrl(text)
+  const openLink = useOpenLink()
 
   const onPressVisit = () => {
     closeModal()
-    Linking.openURL(href)
+    openLink(href)
   }
 
   return (
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index f9d211d07..7f814d971 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -39,6 +39,7 @@ import * as ChangeEmailModal from './ChangeEmail'
 import * as SwitchAccountModal from './SwitchAccount'
 import * as LinkWarningModal from './LinkWarning'
 import * as EmbedConsentModal from './EmbedConsent'
+import * as InAppBrowserConsentModal from './InAppBrowserConsent'
 
 const DEFAULT_SNAPPOINTS = ['90%']
 const HANDLE_HEIGHT = 24
@@ -180,6 +181,9 @@ export function ModalsContainer() {
   } else if (activeModal?.name === 'embed-consent') {
     snapPoints = EmbedConsentModal.snapPoints
     element = <EmbedConsentModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'in-app-browser-consent') {
+    snapPoints = InAppBrowserConsentModal.snapPoints
+    element = <InAppBrowserConsentModal.Component {...activeModal} />
   } else {
     return null
   }
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index dcbec7cb4..4f898767d 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -1,6 +1,5 @@
 import React, {ComponentProps, memo, useMemo} from 'react'
 import {
-  Linking,
   GestureResponderEvent,
   Platform,
   StyleProp,
@@ -31,6 +30,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url'
 import {PressableWithHover} from './PressableWithHover'
 import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
 import {useModalControls} from '#/state/modals'
+import {useOpenLink} from '#/state/preferences/in-app-browser'
 
 type Event =
   | React.MouseEvent<HTMLAnchorElement, MouseEvent>
@@ -65,6 +65,7 @@ export const Link = memo(function Link({
   const {closeModal} = useModalControls()
   const navigation = useNavigation<NavigationProp>()
   const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
+  const openLink = useOpenLink()
 
   const onPress = React.useCallback(
     (e?: Event) => {
@@ -74,11 +75,12 @@ export const Link = memo(function Link({
           navigation,
           sanitizeUrl(href),
           navigationAction,
+          openLink,
           e,
         )
       }
     },
-    [closeModal, navigation, navigationAction, href],
+    [closeModal, navigation, navigationAction, href, openLink],
   )
 
   if (noFeedback) {
@@ -172,6 +174,7 @@ export const TextLink = memo(function TextLink({
   const {...props} = useLinkProps({to: sanitizeUrl(href)})
   const navigation = useNavigation<NavigationProp>()
   const {openModal, closeModal} = useModalControls()
+  const openLink = useOpenLink()
 
   if (warnOnMismatchingLabel && typeof text !== 'string') {
     console.error('Unable to detect mismatching label')
@@ -200,6 +203,7 @@ export const TextLink = memo(function TextLink({
         navigation,
         sanitizeUrl(href),
         navigationAction,
+        openLink,
         e,
       )
     },
@@ -212,6 +216,7 @@ export const TextLink = memo(function TextLink({
       text,
       warnOnMismatchingLabel,
       navigationAction,
+      openLink,
     ],
   )
   const hrefAttrs = useMemo(() => {
@@ -317,6 +322,7 @@ function onPressInner(
   navigation: NavigationProp,
   href: string,
   navigationAction: 'push' | 'replace' | 'navigate' = 'push',
+  openLink: (href: string) => void,
   e?: Event,
 ) {
   let shouldHandle = false
@@ -345,7 +351,7 @@ function onPressInner(
   if (shouldHandle) {
     href = convertBskyAppUrlIfNeeded(href)
     if (newTab || href.startsWith('http') || href.startsWith('mailto')) {
-      Linking.openURL(href)
+      openLink(href)
     } else {
       closeModal() // close any active modals
 
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index c078e7a23..b4a3acbe3 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -70,6 +70,11 @@ import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import {useCloseAllActiveElements} from '#/state/util'
+import {
+  useInAppBrowser,
+  useSetInAppBrowser,
+} from '#/state/preferences/in-app-browser'
+import {isNative} from '#/platform/detection'
 
 function SettingsAccountCard({account}: {account: SessionAccount}) {
   const pal = usePalette('default')
@@ -146,6 +151,8 @@ export function SettingsScreen({}: Props) {
   const setMinimalShellMode = useSetMinimalShellMode()
   const requireAltTextEnabled = useRequireAltTextEnabled()
   const setRequireAltTextEnabled = useSetRequireAltTextEnabled()
+  const inAppBrowserPref = useInAppBrowser()
+  const setUseInAppBrowser = useSetInAppBrowser()
   const onboardingDispatch = useOnboardingDispatch()
   const navigation = useNavigation<NavigationProp>()
   const {isMobile} = useWebMediaQueries()
@@ -658,6 +665,17 @@ export function SettingsScreen({}: Props) {
             <Trans>Change handle</Trans>
           </Text>
         </TouchableOpacity>
+        {isNative && (
+          <View style={[pal.view, styles.toggleCard]}>
+            <ToggleButton
+              type="default-light"
+              label={_(msg`Open links with in-app browser`)}
+              labelType="lg"
+              isSelected={inAppBrowserPref ?? false}
+              onPress={() => setUseInAppBrowser(!inAppBrowserPref)}
+            />
+          </View>
+        )}
         <View style={styles.spacer20} />
         <Text type="xl-bold" style={[pal.text, styles.heading]}>
           <Trans>Danger Zone</Trans>