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 React from 'react'
import { import {
AccessibilityProps, type AccessibilityProps,
GestureResponderEvent, type GestureResponderEvent,
MouseEvent, type MouseEvent,
NativeSyntheticEvent, type NativeSyntheticEvent,
Pressable, Pressable,
PressableProps, type PressableProps,
StyleProp, type StyleProp,
StyleSheet, StyleSheet,
TargetedEvent, type TargetedEvent,
TextProps, type TextProps,
TextStyle, type TextStyle,
View, View,
ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {LinearGradient} from 'expo-linear-gradient' import {LinearGradient} from 'expo-linear-gradient'
import {atoms as a, flatten, select, tokens, useTheme} from '#/alf' 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' import {Text} from '#/components/Typography'
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
@@ -597,7 +597,7 @@ export function useSharedButtonTextStyles() {
if (variant === 'solid' || variant === 'gradient') { if (variant === 'solid' || variant === 'gradient') {
if (!disabled) { if (!disabled) {
baseStyles.push({ baseStyles.push({
color: t.palette.contrast_100, color: t.palette.contrast_50,
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
+3 -3
View File
@@ -2,9 +2,9 @@ import React from 'react'
import {useDialogStateContext} from '#/state/dialogs' import {useDialogStateContext} from '#/state/dialogs'
import { import {
DialogContextProps, type DialogContextProps,
DialogControlRefProps, type DialogControlRefProps,
DialogOuterProps, type DialogOuterProps,
} from '#/components/Dialog/types' } from '#/components/Dialog/types'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.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' 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 = { type ControlsContext = {
mutedWordsDialogControl: Control mutedWordsDialogControl: Control
signinDialogControl: Control signinDialogControl: Control
inAppBrowserConsentControl: StatefulControl<string>
} }
const ControlsContext = React.createContext({ const ControlsContext = createContext<ControlsContext | null>(null)
mutedWordsDialogControl: {} as Control,
signinDialogControl: {} as Control,
})
export function useGlobalDialogsControlContext() { 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<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
const mutedWordsDialogControl = Dialog.useDialogControl() const mutedWordsDialogControl = Dialog.useDialogControl()
const signinDialogControl = Dialog.useDialogControl() const signinDialogControl = Dialog.useDialogControl()
const ctx = React.useMemo<ControlsContext>( const inAppBrowserConsentControl = useStatefulDialogControl<string>()
() => ({mutedWordsDialogControl, signinDialogControl}),
[mutedWordsDialogControl, signinDialogControl], const ctx = useMemo<ControlsContext>(
() => ({
mutedWordsDialogControl,
signinDialogControl,
inAppBrowserConsentControl,
}),
[mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl],
) )
return ( return (
<ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider> <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>
)
}
+15 -6
View File
@@ -12,16 +12,18 @@ import {
toNiceDomain, toNiceDomain,
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useInAppBrowser} from '#/state/preferences/in-app-browser' import {useInAppBrowser} from '#/state/preferences/in-app-browser'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import {useDialogContext} from '#/components/Dialog'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
export function useOpenLink() { export function useOpenLink() {
const {openModal} = useModalControls()
const enabled = useInAppBrowser() const enabled = useInAppBrowser()
const t = useTheme() const t = useTheme()
const sheetWrapper = useSheetWrapper() const sheetWrapper = useSheetWrapper()
const dialogContext = useDialogContext()
const {inAppBrowserConsentControl} = useGlobalDialogsControlContext()
const openLink = useCallback( const openLink = useCallback(
async (url: string, override?: boolean, shouldProxy?: boolean) => { async (url: string, override?: boolean, shouldProxy?: boolean) => {
@@ -42,10 +44,17 @@ export function useOpenLink() {
if (isNative && !url.startsWith('mailto:')) { if (isNative && !url.startsWith('mailto:')) {
if (override === undefined && enabled === undefined) { if (override === undefined && enabled === undefined) {
openModal({ // consent dialog is a global dialog, and while it's possible to nest dialogs,
name: 'in-app-browser-consent', // the actual components need to be nested. sibling dialogs on iOS are not supported.
href: url, // 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 return
} else if (override ?? enabled) { } else if (override ?? enabled) {
await sheetWrapper( await sheetWrapper(
@@ -62,7 +71,7 @@ export function useOpenLink() {
} }
Linking.openURL(url) Linking.openURL(url)
}, },
[enabled, openModal, t, sheetWrapper], [enabled, inAppBrowserConsentControl, t, sheetWrapper, dialogContext],
) )
return openLink return openLink
-6
View File
@@ -66,11 +66,6 @@ export interface LinkWarningModal {
share?: boolean share?: boolean
} }
export interface InAppBrowserConsentModal {
name: 'in-app-browser-consent'
href: string
}
export type Modal = export type Modal =
// Account // Account
| DeleteAccountModal | DeleteAccountModal
@@ -96,7 +91,6 @@ export type Modal =
// Generic // Generic
| LinkWarningModal | LinkWarningModal
| InAppBrowserConsentModal
const ModalContext = React.createContext<{ const ModalContext = React.createContext<{
isModalActive: boolean 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 CreateOrEditListModal from './CreateOrEditList'
import * as DeleteAccountModal from './DeleteAccount' import * as DeleteAccountModal from './DeleteAccount'
import * as EditProfileModal from './EditProfile' import * as EditProfileModal from './EditProfile'
import * as InAppBrowserConsentModal from './InAppBrowserConsent'
import * as InviteCodesModal from './InviteCodes' import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
@@ -76,9 +75,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'link-warning') { } else if (activeModal?.name === 'link-warning') {
snapPoints = LinkWarningModal.snapPoints snapPoints = LinkWarningModal.snapPoints
element = <LinkWarningModal.Component {...activeModal} /> element = <LinkWarningModal.Component {...activeModal} />
} else if (activeModal?.name === 'in-app-browser-consent') {
snapPoints = InAppBrowserConsentModal.snapPoints
element = <InAppBrowserConsentModal.Component {...activeModal} />
} else { } else {
return null return null
} }
+2
View File
@@ -25,6 +25,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, select, useTheme} from '#/alf' import {atoms as a, select, useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI' import {setSystemUITheme} from '#/alf/util/systemUI'
import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin' import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal' import {Outlet as PortalOutlet} from '#/components/Portal'
@@ -151,6 +152,7 @@ function ShellInner() {
<ModalsContainer /> <ModalsContainer />
<MutedWordsDialog /> <MutedWordsDialog />
<SigninDialog /> <SigninDialog />
<InAppBrowserConsentDialog />
<Lightbox /> <Lightbox />
<PortalOutlet /> <PortalOutlet />
<BottomSheetOutlet /> <BottomSheetOutlet />