An ATproto social media client -- with an independent Appview.

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>

authored by samuel.fm

Eric Bailey and committed by
GitHub
8ec8a644 69f656f2

+188 -141
+12 -12
src/components/Button.tsx
··· 1 1 import React from 'react' 2 2 import { 3 - AccessibilityProps, 4 - GestureResponderEvent, 5 - MouseEvent, 6 - NativeSyntheticEvent, 3 + type AccessibilityProps, 4 + type GestureResponderEvent, 5 + type MouseEvent, 6 + type NativeSyntheticEvent, 7 7 Pressable, 8 - PressableProps, 9 - StyleProp, 8 + type PressableProps, 9 + type StyleProp, 10 10 StyleSheet, 11 - TargetedEvent, 12 - TextProps, 13 - TextStyle, 11 + type TargetedEvent, 12 + type TextProps, 13 + type TextStyle, 14 14 View, 15 - ViewStyle, 15 + type ViewStyle, 16 16 } from 'react-native' 17 17 import {LinearGradient} from 'expo-linear-gradient' 18 18 19 19 import {atoms as a, flatten, select, tokens, useTheme} from '#/alf' 20 - import {Props as SVGIconProps} from '#/components/icons/common' 20 + import {type Props as SVGIconProps} from '#/components/icons/common' 21 21 import {Text} from '#/components/Typography' 22 22 23 23 export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' ··· 597 597 if (variant === 'solid' || variant === 'gradient') { 598 598 if (!disabled) { 599 599 baseStyles.push({ 600 - color: t.palette.contrast_100, 600 + color: t.palette.contrast_50, 601 601 }) 602 602 } else { 603 603 baseStyles.push({
+3 -3
src/components/Dialog/context.ts
··· 2 2 3 3 import {useDialogStateContext} from '#/state/dialogs' 4 4 import { 5 - DialogContextProps, 6 - DialogControlRefProps, 7 - DialogOuterProps, 5 + type DialogContextProps, 6 + type DialogControlRefProps, 7 + type DialogOuterProps, 8 8 } from '#/components/Dialog/types' 9 9 import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types' 10 10
+44 -10
src/components/dialogs/Context.tsx
··· 1 - import React from 'react' 1 + import {createContext, useContext, useMemo, useState} from 'react' 2 2 3 3 import * as Dialog from '#/components/Dialog' 4 4 5 - type Control = Dialog.DialogOuterProps['control'] 5 + type Control = Dialog.DialogControlProps 6 + 7 + export type StatefulControl<T> = { 8 + control: Control 9 + open: (value: T) => void 10 + clear: () => void 11 + value: T | undefined 12 + } 6 13 7 14 type ControlsContext = { 8 15 mutedWordsDialogControl: Control 9 16 signinDialogControl: Control 17 + inAppBrowserConsentControl: StatefulControl<string> 10 18 } 11 19 12 - const ControlsContext = React.createContext({ 13 - mutedWordsDialogControl: {} as Control, 14 - signinDialogControl: {} as Control, 15 - }) 20 + const ControlsContext = createContext<ControlsContext | null>(null) 16 21 17 22 export function useGlobalDialogsControlContext() { 18 - return React.useContext(ControlsContext) 23 + const ctx = useContext(ControlsContext) 24 + if (!ctx) { 25 + throw new Error( 26 + 'useGlobalDialogsControlContext must be used within a Provider', 27 + ) 28 + } 29 + return ctx 19 30 } 20 31 21 32 export function Provider({children}: React.PropsWithChildren<{}>) { 22 33 const mutedWordsDialogControl = Dialog.useDialogControl() 23 34 const signinDialogControl = Dialog.useDialogControl() 24 - const ctx = React.useMemo<ControlsContext>( 25 - () => ({mutedWordsDialogControl, signinDialogControl}), 26 - [mutedWordsDialogControl, signinDialogControl], 35 + const inAppBrowserConsentControl = useStatefulDialogControl<string>() 36 + 37 + const ctx = useMemo<ControlsContext>( 38 + () => ({ 39 + mutedWordsDialogControl, 40 + signinDialogControl, 41 + inAppBrowserConsentControl, 42 + }), 43 + [mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl], 27 44 ) 28 45 29 46 return ( 30 47 <ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider> 31 48 ) 32 49 } 50 + 51 + function useStatefulDialogControl<T>(initialValue?: T): StatefulControl<T> { 52 + const [value, setValue] = useState(initialValue) 53 + const control = Dialog.useDialogControl() 54 + return useMemo( 55 + () => ({ 56 + control, 57 + open: (v: T) => { 58 + setValue(v) 59 + control.open() 60 + }, 61 + clear: () => setValue(initialValue), 62 + value, 63 + }), 64 + [control, value, initialValue], 65 + ) 66 + }
+111
src/components/dialogs/InAppBrowserConsent.tsx
··· 1 + import {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {useOpenLink} from '#/lib/hooks/useOpenLink' 7 + import {isWeb} from '#/platform/detection' 8 + import {useSetInAppBrowser} from '#/state/preferences/in-app-browser' 9 + import {atoms as a, useTheme} from '#/alf' 10 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 11 + import * as Dialog from '#/components/Dialog' 12 + import {SquareArrowTopRight_Stroke2_Corner0_Rounded as External} from '#/components/icons/SquareArrowTopRight' 13 + import {Text} from '#/components/Typography' 14 + import {useGlobalDialogsControlContext} from './Context' 15 + 16 + export function InAppBrowserConsentDialog() { 17 + const {inAppBrowserConsentControl} = useGlobalDialogsControlContext() 18 + 19 + if (isWeb) return null 20 + 21 + return ( 22 + <Dialog.Outer 23 + control={inAppBrowserConsentControl.control} 24 + nativeOptions={{preventExpansion: true}} 25 + onClose={inAppBrowserConsentControl.clear}> 26 + <Dialog.Handle /> 27 + <InAppBrowserConsentInner href={inAppBrowserConsentControl.value} /> 28 + </Dialog.Outer> 29 + ) 30 + } 31 + 32 + function InAppBrowserConsentInner({href}: {href?: string}) { 33 + const control = Dialog.useDialogContext() 34 + const {_} = useLingui() 35 + const t = useTheme() 36 + const setInAppBrowser = useSetInAppBrowser() 37 + const openLink = useOpenLink() 38 + 39 + const onUseIAB = useCallback(() => { 40 + control.close(() => { 41 + setInAppBrowser(true) 42 + if (href) { 43 + openLink(href, true) 44 + } 45 + }) 46 + }, [control, setInAppBrowser, href, openLink]) 47 + 48 + const onUseLinking = useCallback(() => { 49 + control.close(() => { 50 + setInAppBrowser(false) 51 + if (href) { 52 + openLink(href, false) 53 + } 54 + }) 55 + }, [control, setInAppBrowser, href, openLink]) 56 + 57 + const onCancel = useCallback(() => { 58 + control.close() 59 + }, [control]) 60 + 61 + return ( 62 + <Dialog.ScrollableInner label={_(msg`How should we open this link?`)}> 63 + <View style={[a.gap_2xl]}> 64 + <View style={[a.gap_sm]}> 65 + <Text style={[a.font_heavy, a.text_2xl]}> 66 + <Trans>How should we open this link?</Trans> 67 + </Text> 68 + <Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_md]}> 69 + <Trans> 70 + Your choice will be remembered for future links. You can change it 71 + at any time in settings. 72 + </Trans> 73 + </Text> 74 + </View> 75 + <View style={[a.gap_sm]}> 76 + <Button 77 + label={_(msg`Use in-app browser`)} 78 + onPress={onUseIAB} 79 + size="large" 80 + variant="solid" 81 + color="primary"> 82 + <ButtonText> 83 + <Trans>Use in-app browser</Trans> 84 + </ButtonText> 85 + </Button> 86 + <Button 87 + label={_(msg`Use my default browser`)} 88 + onPress={onUseLinking} 89 + size="large" 90 + variant="solid" 91 + color="secondary"> 92 + <ButtonText> 93 + <Trans>Use my default browser</Trans> 94 + </ButtonText> 95 + <ButtonIcon position="right" icon={External} /> 96 + </Button> 97 + <Button 98 + label={_(msg`Cancel`)} 99 + onPress={onCancel} 100 + size="large" 101 + variant="ghost" 102 + color="secondary"> 103 + <ButtonText> 104 + <Trans>Cancel</Trans> 105 + </ButtonText> 106 + </Button> 107 + </View> 108 + </View> 109 + </Dialog.ScrollableInner> 110 + ) 111 + }
+16 -7
src/lib/hooks/useOpenLink.ts
··· 12 12 toNiceDomain, 13 13 } from '#/lib/strings/url-helpers' 14 14 import {isNative} from '#/platform/detection' 15 - import {useModalControls} from '#/state/modals' 16 15 import {useInAppBrowser} from '#/state/preferences/in-app-browser' 17 16 import {useTheme} from '#/alf' 17 + import {useDialogContext} from '#/components/Dialog' 18 18 import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 19 + import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 19 20 20 21 export function useOpenLink() { 21 - const {openModal} = useModalControls() 22 22 const enabled = useInAppBrowser() 23 23 const t = useTheme() 24 24 const sheetWrapper = useSheetWrapper() 25 + const dialogContext = useDialogContext() 26 + const {inAppBrowserConsentControl} = useGlobalDialogsControlContext() 25 27 26 28 const openLink = useCallback( 27 29 async (url: string, override?: boolean, shouldProxy?: boolean) => { ··· 42 44 43 45 if (isNative && !url.startsWith('mailto:')) { 44 46 if (override === undefined && enabled === undefined) { 45 - openModal({ 46 - name: 'in-app-browser-consent', 47 - href: url, 48 - }) 47 + // consent dialog is a global dialog, and while it's possible to nest dialogs, 48 + // the actual components need to be nested. sibling dialogs on iOS are not supported. 49 + // thus, check if we're in a dialog, and if so, close the existing dialog before opening the 50 + // consent dialog -sfn 51 + if (dialogContext.isWithinDialog) { 52 + dialogContext.close(() => { 53 + inAppBrowserConsentControl.open(url) 54 + }) 55 + } else { 56 + inAppBrowserConsentControl.open(url) 57 + } 49 58 return 50 59 } else if (override ?? enabled) { 51 60 await sheetWrapper( ··· 62 71 } 63 72 Linking.openURL(url) 64 73 }, 65 - [enabled, openModal, t, sheetWrapper], 74 + [enabled, inAppBrowserConsentControl, t, sheetWrapper, dialogContext], 66 75 ) 67 76 68 77 return openLink
-6
src/state/modals/index.tsx
··· 66 66 share?: boolean 67 67 } 68 68 69 - export interface InAppBrowserConsentModal { 70 - name: 'in-app-browser-consent' 71 - href: string 72 - } 73 - 74 69 export type Modal = 75 70 // Account 76 71 | DeleteAccountModal ··· 96 91 97 92 // Generic 98 93 | LinkWarningModal 99 - | InAppBrowserConsentModal 100 94 101 95 const ModalContext = React.createContext<{ 102 96 isModalActive: boolean
-99
src/view/com/modals/InAppBrowserConsent.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, View} from 'react-native' 3 - import {msg, Trans} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - 6 - import {useOpenLink} from '#/lib/hooks/useOpenLink' 7 - import {usePalette} from '#/lib/hooks/usePalette' 8 - import {s} from '#/lib/styles' 9 - import {useModalControls} from '#/state/modals' 10 - import {useSetInAppBrowser} from '#/state/preferences/in-app-browser' 11 - import {ScrollView} from '#/view/com/modals/util' 12 - import {Button} from '#/view/com/util/forms/Button' 13 - import {Text} from '#/view/com/util/text/Text' 14 - 15 - export const snapPoints = [350] 16 - 17 - export function Component({href}: {href: string}) { 18 - const pal = usePalette('default') 19 - const {closeModal} = useModalControls() 20 - const {_} = useLingui() 21 - const setInAppBrowser = useSetInAppBrowser() 22 - const openLink = useOpenLink() 23 - 24 - const onUseIAB = React.useCallback(() => { 25 - setInAppBrowser(true) 26 - closeModal() 27 - openLink(href, true) 28 - }, [closeModal, setInAppBrowser, href, openLink]) 29 - 30 - const onUseLinking = React.useCallback(() => { 31 - setInAppBrowser(false) 32 - closeModal() 33 - openLink(href, false) 34 - }, [closeModal, setInAppBrowser, href, openLink]) 35 - 36 - return ( 37 - <ScrollView 38 - testID="inAppBrowserConsentModal" 39 - style={[s.flex1, pal.view, {paddingHorizontal: 20, paddingTop: 10}]}> 40 - <Text style={[pal.text, styles.title]}> 41 - <Trans>How should we open this link?</Trans> 42 - </Text> 43 - <Text style={pal.text}> 44 - <Trans> 45 - Your choice will be saved, but can be changed later in settings. 46 - </Trans> 47 - </Text> 48 - <View style={[styles.btnContainer]}> 49 - <Button 50 - testID="confirmBtn" 51 - type="inverted" 52 - onPress={onUseIAB} 53 - accessibilityLabel={_(msg`Use in-app browser`)} 54 - accessibilityHint="" 55 - label={_(msg`Use in-app browser`)} 56 - labelContainerStyle={{justifyContent: 'center', padding: 8}} 57 - labelStyle={[s.f18]} 58 - /> 59 - <Button 60 - testID="confirmBtn" 61 - type="inverted" 62 - onPress={onUseLinking} 63 - accessibilityLabel={_(msg`Use my default browser`)} 64 - accessibilityHint="" 65 - label={_(msg`Use my default browser`)} 66 - labelContainerStyle={{justifyContent: 'center', padding: 8}} 67 - labelStyle={[s.f18]} 68 - /> 69 - <Button 70 - testID="cancelBtn" 71 - type="default" 72 - onPress={() => { 73 - closeModal() 74 - }} 75 - accessibilityLabel={_(msg`Cancel`)} 76 - accessibilityHint="" 77 - label={_(msg`Cancel`)} 78 - labelContainerStyle={{justifyContent: 'center', padding: 8}} 79 - labelStyle={[s.f18]} 80 - /> 81 - </View> 82 - </ScrollView> 83 - ) 84 - } 85 - 86 - const styles = StyleSheet.create({ 87 - title: { 88 - textAlign: 'center', 89 - fontWeight: '600', 90 - fontSize: 24, 91 - marginBottom: 12, 92 - }, 93 - btnContainer: { 94 - marginTop: 20, 95 - flexDirection: 'column', 96 - justifyContent: 'center', 97 - rowGap: 10, 98 - }, 99 - })
-4
src/view/com/modals/Modal.tsx
··· 11 11 import * as CreateOrEditListModal from './CreateOrEditList' 12 12 import * as DeleteAccountModal from './DeleteAccount' 13 13 import * as EditProfileModal from './EditProfile' 14 - import * as InAppBrowserConsentModal from './InAppBrowserConsent' 15 14 import * as InviteCodesModal from './InviteCodes' 16 15 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 17 16 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' ··· 76 75 } else if (activeModal?.name === 'link-warning') { 77 76 snapPoints = LinkWarningModal.snapPoints 78 77 element = <LinkWarningModal.Component {...activeModal} /> 79 - } else if (activeModal?.name === 'in-app-browser-consent') { 80 - snapPoints = InAppBrowserConsentModal.snapPoints 81 - element = <InAppBrowserConsentModal.Component {...activeModal} /> 82 78 } else { 83 79 return null 84 80 }
+2
src/view/shell/index.tsx
··· 25 25 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 26 26 import {atoms as a, select, useTheme} from '#/alf' 27 27 import {setSystemUITheme} from '#/alf/util/systemUI' 28 + import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' 28 29 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' 29 30 import {SigninDialog} from '#/components/dialogs/Signin' 30 31 import {Outlet as PortalOutlet} from '#/components/Portal' ··· 151 152 <ModalsContainer /> 152 153 <MutedWordsDialog /> 153 154 <SigninDialog /> 155 + <InAppBrowserConsentDialog /> 154 156 <Lightbox /> 155 157 <PortalOutlet /> 156 158 <BottomSheetOutlet />