(optional) In app browser (#2490)

* add expo web browser + modal

* add in app browser option to settings

* don't show toggle on web

* Tweak browser-choice UIs

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Paul Frazee 2024-01-12 10:19:56 -08:00 committed by GitHub
parent b147f7ae8a
commit 998ee29986
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 299 additions and 22 deletions

View file

@ -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

View file

@ -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,
}

View file

@ -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
}

View file

@ -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>

View file

@ -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,
},
})

View file

@ -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 (

View file

@ -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
}

View file

@ -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

View file

@ -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>