Modernize in-app browser consent dialog (#8191)

* add stateful dialog control hook

* add new alf'd consent

* make secondary_inverted buttons clearer

* contingency for opening a link from another dialog

* rm old modal

* Differentiate buttons more

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2025-04-25 00:41:11 +03:00
committed by GitHub
parent 69f656f283
commit 8ec8a64472
9 changed files with 188 additions and 141 deletions
+12 -12
View File
@@ -1,23 +1,23 @@
import React from 'react'
import {
AccessibilityProps,
GestureResponderEvent,
MouseEvent,
NativeSyntheticEvent,
type AccessibilityProps,
type GestureResponderEvent,
type MouseEvent,
type NativeSyntheticEvent,
Pressable,
PressableProps,
StyleProp,
type PressableProps,
type StyleProp,
StyleSheet,
TargetedEvent,
TextProps,
TextStyle,
type TargetedEvent,
type TextProps,
type TextStyle,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {atoms as a, flatten, select, tokens, useTheme} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography'
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
@@ -597,7 +597,7 @@ export function useSharedButtonTextStyles() {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({
color: t.palette.contrast_100,
color: t.palette.contrast_50,
})
} else {
baseStyles.push({
+3 -3
View File
@@ -2,9 +2,9 @@ import React from 'react'
import {useDialogStateContext} from '#/state/dialogs'
import {
DialogContextProps,
DialogControlRefProps,
DialogOuterProps,
type DialogContextProps,
type DialogControlRefProps,
type DialogOuterProps,
} from '#/components/Dialog/types'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
+44 -10
View File
@@ -1,32 +1,66 @@
import React from 'react'
import {createContext, useContext, useMemo, useState} from 'react'
import * as Dialog from '#/components/Dialog'
type Control = Dialog.DialogOuterProps['control']
type Control = Dialog.DialogControlProps
export type StatefulControl<T> = {
control: Control
open: (value: T) => void
clear: () => void
value: T | undefined
}
type ControlsContext = {
mutedWordsDialogControl: Control
signinDialogControl: Control
inAppBrowserConsentControl: StatefulControl<string>
}
const ControlsContext = React.createContext({
mutedWordsDialogControl: {} as Control,
signinDialogControl: {} as Control,
})
const ControlsContext = createContext<ControlsContext | null>(null)
export function useGlobalDialogsControlContext() {
return React.useContext(ControlsContext)
const ctx = useContext(ControlsContext)
if (!ctx) {
throw new Error(
'useGlobalDialogsControlContext must be used within a Provider',
)
}
return ctx
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const mutedWordsDialogControl = Dialog.useDialogControl()
const signinDialogControl = Dialog.useDialogControl()
const ctx = React.useMemo<ControlsContext>(
() => ({mutedWordsDialogControl, signinDialogControl}),
[mutedWordsDialogControl, signinDialogControl],
const inAppBrowserConsentControl = useStatefulDialogControl<string>()
const ctx = useMemo<ControlsContext>(
() => ({
mutedWordsDialogControl,
signinDialogControl,
inAppBrowserConsentControl,
}),
[mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl],
)
return (
<ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider>
)
}
function useStatefulDialogControl<T>(initialValue?: T): StatefulControl<T> {
const [value, setValue] = useState(initialValue)
const control = Dialog.useDialogControl()
return useMemo(
() => ({
control,
open: (v: T) => {
setValue(v)
control.open()
},
clear: () => setValue(initialValue),
value,
}),
[control, value, initialValue],
)
}
@@ -0,0 +1,111 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {isWeb} from '#/platform/detection'
import {useSetInAppBrowser} from '#/state/preferences/in-app-browser'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as External} from '#/components/icons/SquareArrowTopRight'
import {Text} from '#/components/Typography'
import {useGlobalDialogsControlContext} from './Context'
export function InAppBrowserConsentDialog() {
const {inAppBrowserConsentControl} = useGlobalDialogsControlContext()
if (isWeb) return null
return (
<Dialog.Outer
control={inAppBrowserConsentControl.control}
nativeOptions={{preventExpansion: true}}
onClose={inAppBrowserConsentControl.clear}>
<Dialog.Handle />
<InAppBrowserConsentInner href={inAppBrowserConsentControl.value} />
</Dialog.Outer>
)
}
function InAppBrowserConsentInner({href}: {href?: string}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const t = useTheme()
const setInAppBrowser = useSetInAppBrowser()
const openLink = useOpenLink()
const onUseIAB = useCallback(() => {
control.close(() => {
setInAppBrowser(true)
if (href) {
openLink(href, true)
}
})
}, [control, setInAppBrowser, href, openLink])
const onUseLinking = useCallback(() => {
control.close(() => {
setInAppBrowser(false)
if (href) {
openLink(href, false)
}
})
}, [control, setInAppBrowser, href, openLink])
const onCancel = useCallback(() => {
control.close()
}, [control])
return (
<Dialog.ScrollableInner label={_(msg`How should we open this link?`)}>
<View style={[a.gap_2xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
<Trans>How should we open this link?</Trans>
</Text>
<Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_md]}>
<Trans>
Your choice will be remembered for future links. You can change it
at any time in settings.
</Trans>
</Text>
</View>
<View style={[a.gap_sm]}>
<Button
label={_(msg`Use in-app browser`)}
onPress={onUseIAB}
size="large"
variant="solid"
color="primary">
<ButtonText>
<Trans>Use in-app browser</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Use my default browser`)}
onPress={onUseLinking}
size="large"
variant="solid"
color="secondary">
<ButtonText>
<Trans>Use my default browser</Trans>
</ButtonText>
<ButtonIcon position="right" icon={External} />
</Button>
<Button
label={_(msg`Cancel`)}
onPress={onCancel}
size="large"
variant="ghost"
color="secondary">
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
</Dialog.ScrollableInner>
)
}
+16 -7
View File
@@ -12,16 +12,18 @@ import {
toNiceDomain,
} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useInAppBrowser} from '#/state/preferences/in-app-browser'
import {useTheme} from '#/alf'
import {useDialogContext} from '#/components/Dialog'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
export function useOpenLink() {
const {openModal} = useModalControls()
const enabled = useInAppBrowser()
const t = useTheme()
const sheetWrapper = useSheetWrapper()
const dialogContext = useDialogContext()
const {inAppBrowserConsentControl} = useGlobalDialogsControlContext()
const openLink = useCallback(
async (url: string, override?: boolean, shouldProxy?: boolean) => {
@@ -42,10 +44,17 @@ export function useOpenLink() {
if (isNative && !url.startsWith('mailto:')) {
if (override === undefined && enabled === undefined) {
openModal({
name: 'in-app-browser-consent',
href: url,
})
// consent dialog is a global dialog, and while it's possible to nest dialogs,
// the actual components need to be nested. sibling dialogs on iOS are not supported.
// thus, check if we're in a dialog, and if so, close the existing dialog before opening the
// consent dialog -sfn
if (dialogContext.isWithinDialog) {
dialogContext.close(() => {
inAppBrowserConsentControl.open(url)
})
} else {
inAppBrowserConsentControl.open(url)
}
return
} else if (override ?? enabled) {
await sheetWrapper(
@@ -62,7 +71,7 @@ export function useOpenLink() {
}
Linking.openURL(url)
},
[enabled, openModal, t, sheetWrapper],
[enabled, inAppBrowserConsentControl, t, sheetWrapper, dialogContext],
)
return openLink
-6
View File
@@ -66,11 +66,6 @@ export interface LinkWarningModal {
share?: boolean
}
export interface InAppBrowserConsentModal {
name: 'in-app-browser-consent'
href: string
}
export type Modal =
// Account
| DeleteAccountModal
@@ -96,7 +91,6 @@ export type Modal =
// Generic
| LinkWarningModal
| InAppBrowserConsentModal
const ModalContext = React.createContext<{
isModalActive: boolean
@@ -1,99 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {usePalette} from '#/lib/hooks/usePalette'
import {s} from '#/lib/styles'
import {useModalControls} from '#/state/modals'
import {useSetInAppBrowser} from '#/state/preferences/in-app-browser'
import {ScrollView} from '#/view/com/modals/util'
import {Button} from '#/view/com/util/forms/Button'
import {Text} from '#/view/com/util/text/Text'
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={_(msg`Cancel`)}
labelContainerStyle={{justifyContent: 'center', padding: 8}}
labelStyle={[s.f18]}
/>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
title: {
textAlign: 'center',
fontWeight: '600',
fontSize: 24,
marginBottom: 12,
},
btnContainer: {
marginTop: 20,
flexDirection: 'column',
justifyContent: 'center',
rowGap: 10,
},
})
-4
View File
@@ -11,7 +11,6 @@ import * as ChangePasswordModal from './ChangePassword'
import * as CreateOrEditListModal from './CreateOrEditList'
import * as DeleteAccountModal from './DeleteAccount'
import * as EditProfileModal from './EditProfile'
import * as InAppBrowserConsentModal from './InAppBrowserConsent'
import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
@@ -76,9 +75,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'link-warning') {
snapPoints = LinkWarningModal.snapPoints
element = <LinkWarningModal.Component {...activeModal} />
} else if (activeModal?.name === 'in-app-browser-consent') {
snapPoints = InAppBrowserConsentModal.snapPoints
element = <InAppBrowserConsentModal.Component {...activeModal} />
} else {
return null
}
+2
View File
@@ -25,6 +25,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, select, useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal'
@@ -151,6 +152,7 @@ function ShellInner() {
<ModalsContainer />
<MutedWordsDialog />
<SigninDialog />
<InAppBrowserConsentDialog />
<Lightbox />
<PortalOutlet />
<BottomSheetOutlet />