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

Modernise link warning dialog (#8243)

* add link warning dialog

* add copy for if sharing

* delete old modal

* get web working

authored by samuel.fm and committed by

GitHub 4da86e58 dd864027

+200 -213
+17 -11
src/components/Link.tsx
··· 24 24 import {useInteractionState} from '#/components/hooks/useInteractionState' 25 25 import {Text, type TextProps} from '#/components/Typography' 26 26 import {router} from '#/routes' 27 + import {useGlobalDialogsControlContext} from './dialogs/Context' 27 28 28 29 /** 29 30 * Only available within a `Link`, since that inherits from `Button`. ··· 111 112 } 112 113 113 114 const isExternal = isExternalUrl(href) 114 - const {openModal, closeModal} = useModalControls() 115 + const {closeModal} = useModalControls() 116 + const {linkWarningDialogControl} = useGlobalDialogsControlContext() 115 117 const openLink = useOpenLink() 116 118 117 119 const onPress = React.useCallback( ··· 132 134 } 133 135 134 136 if (requiresWarning) { 135 - openModal({ 136 - name: 'link-warning', 137 - text: displayText, 138 - href: href, 137 + linkWarningDialogControl.open({ 138 + displayText, 139 + href, 139 140 }) 140 141 } else { 141 142 if (isExternal) { ··· 176 177 displayText, 177 178 isExternal, 178 179 href, 179 - openModal, 180 180 openLink, 181 181 closeModal, 182 182 action, 183 183 navigation, 184 184 overridePresentation, 185 185 shouldProxy, 186 + linkWarningDialogControl, 186 187 ], 187 188 ) 188 189 ··· 195 196 ) 196 197 197 198 if (requiresWarning) { 198 - openModal({ 199 - name: 'link-warning', 200 - text: displayText, 201 - href: href, 199 + linkWarningDialogControl.open({ 200 + displayText, 201 + href, 202 202 share: true, 203 203 }) 204 204 } else { 205 205 shareUrl(href) 206 206 } 207 - }, [disableMismatchWarning, displayText, href, isExternal, openModal]) 207 + }, [ 208 + disableMismatchWarning, 209 + displayText, 210 + href, 211 + isExternal, 212 + linkWarningDialogControl, 213 + ]) 208 214 209 215 const onLongPress = React.useCallback( 210 216 (e: GestureResponderEvent) => {
+12
src/components/dialogs/Context.tsx
··· 17 17 signinDialogControl: Control 18 18 inAppBrowserConsentControl: StatefulControl<string> 19 19 emailDialogControl: StatefulControl<Screen> 20 + linkWarningDialogControl: StatefulControl<{ 21 + href: string 22 + displayText: string 23 + share?: boolean 24 + }> 20 25 } 21 26 22 27 const ControlsContext = createContext<ControlsContext | null>(null) ··· 36 41 const signinDialogControl = Dialog.useDialogControl() 37 42 const inAppBrowserConsentControl = useStatefulDialogControl<string>() 38 43 const emailDialogControl = useStatefulDialogControl<Screen>() 44 + const linkWarningDialogControl = useStatefulDialogControl<{ 45 + href: string 46 + displayText: string 47 + share?: boolean 48 + }>() 39 49 40 50 const ctx = useMemo<ControlsContext>( 41 51 () => ({ ··· 43 53 signinDialogControl, 44 54 inAppBrowserConsentControl, 45 55 emailDialogControl, 56 + linkWarningDialogControl, 46 57 }), 47 58 [ 48 59 mutedWordsDialogControl, 49 60 signinDialogControl, 50 61 inAppBrowserConsentControl, 51 62 emailDialogControl, 63 + linkWarningDialogControl, 52 64 ], 53 65 ) 54 66
+161
src/components/dialogs/LinkWarning.tsx
··· 1 + import {useCallback, useMemo} 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 {shareUrl} from '#/lib/sharing' 8 + import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers' 9 + import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 10 + import {Button, ButtonText} from '#/components/Button' 11 + import * as Dialog from '#/components/Dialog' 12 + import {Text} from '#/components/Typography' 13 + import {useGlobalDialogsControlContext} from './Context' 14 + 15 + export function LinkWarningDialog() { 16 + const {linkWarningDialogControl} = useGlobalDialogsControlContext() 17 + 18 + return ( 19 + <Dialog.Outer 20 + control={linkWarningDialogControl.control} 21 + nativeOptions={{preventExpansion: true}} 22 + webOptions={{alignCenter: true}} 23 + onClose={linkWarningDialogControl.clear}> 24 + <Dialog.Handle /> 25 + <InAppBrowserConsentInner link={linkWarningDialogControl.value} /> 26 + </Dialog.Outer> 27 + ) 28 + } 29 + 30 + function InAppBrowserConsentInner({ 31 + link, 32 + }: { 33 + link?: {href: string; displayText: string; share?: boolean} 34 + }) { 35 + const control = Dialog.useDialogContext() 36 + const {_} = useLingui() 37 + const t = useTheme() 38 + const openLink = useOpenLink() 39 + const {gtMobile} = useBreakpoints() 40 + 41 + const potentiallyMisleading = useMemo( 42 + () => link && isPossiblyAUrl(link.displayText), 43 + [link], 44 + ) 45 + 46 + const onPressVisit = useCallback(() => { 47 + control.close(() => { 48 + if (!link) return 49 + if (link.share) { 50 + shareUrl(link.href) 51 + } else { 52 + openLink(link.href, undefined, true) 53 + } 54 + }) 55 + }, [control, link, openLink]) 56 + 57 + const onCancel = useCallback(() => { 58 + control.close() 59 + }, [control]) 60 + 61 + return ( 62 + <Dialog.ScrollableInner 63 + style={web({maxWidth: 450})} 64 + label={ 65 + potentiallyMisleading 66 + ? _(msg`Potentially misleading link warning`) 67 + : _(msg`Leaving Bluesky`) 68 + }> 69 + <View style={[a.gap_2xl]}> 70 + <View style={[a.gap_sm]}> 71 + <Text style={[a.font_heavy, a.text_2xl]}> 72 + {potentiallyMisleading ? ( 73 + <Trans>Potentially misleading link</Trans> 74 + ) : ( 75 + <Trans>Leaving Bluesky</Trans> 76 + )} 77 + </Text> 78 + <Text style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}> 79 + <Trans>This link is taking you to the following website:</Trans> 80 + </Text> 81 + {link && <LinkBox href={link.href} />} 82 + {potentiallyMisleading && ( 83 + <Text 84 + style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}> 85 + <Trans>Make sure this is where you intend to go!</Trans> 86 + </Text> 87 + )} 88 + </View> 89 + <View 90 + style={[ 91 + a.flex_1, 92 + a.gap_sm, 93 + gtMobile && [a.flex_row_reverse, a.justify_start], 94 + ]}> 95 + <Button 96 + label={link?.share ? _(msg`Share link`) : _(msg`Visit site`)} 97 + accessibilityHint={_(msg`Opens link ${link?.href ?? ''}`)} 98 + onPress={onPressVisit} 99 + size="large" 100 + variant="solid" 101 + color={potentiallyMisleading ? 'secondary_inverted' : 'primary'}> 102 + <ButtonText> 103 + {link?.share ? ( 104 + <Trans>Share link</Trans> 105 + ) : ( 106 + <Trans>Visit site</Trans> 107 + )} 108 + </ButtonText> 109 + </Button> 110 + <Button 111 + label={_(msg`Go back`)} 112 + onPress={onCancel} 113 + size="large" 114 + variant="ghost" 115 + color="secondary"> 116 + <ButtonText> 117 + <Trans>Go back</Trans> 118 + </ButtonText> 119 + </Button> 120 + </View> 121 + </View> 122 + <Dialog.Close /> 123 + </Dialog.ScrollableInner> 124 + ) 125 + } 126 + 127 + function LinkBox({href}: {href: string}) { 128 + const t = useTheme() 129 + const [scheme, hostname, rest] = useMemo(() => { 130 + try { 131 + const urlp = new URL(href) 132 + const [subdomain, apexdomain] = splitApexDomain(urlp.hostname) 133 + return [ 134 + urlp.protocol + '//' + subdomain, 135 + apexdomain, 136 + urlp.pathname.replace(/\/$/, '') + urlp.search + urlp.hash, 137 + ] 138 + } catch { 139 + return ['', href, ''] 140 + } 141 + }, [href]) 142 + return ( 143 + <View 144 + style={[ 145 + t.atoms.bg, 146 + t.atoms.border_contrast_medium, 147 + a.px_md, 148 + {paddingVertical: 10}, 149 + a.rounded_sm, 150 + a.border, 151 + ]}> 152 + <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> 153 + {scheme} 154 + <Text style={[a.text_md, a.leading_snug, t.atoms.text, a.font_bold]}> 155 + {hostname} 156 + </Text> 157 + {rest} 158 + </Text> 159 + </View> 160 + ) 161 + }
-10
src/state/modals/index.tsx
··· 43 43 name: 'change-password' 44 44 } 45 45 46 - export interface LinkWarningModal { 47 - name: 'link-warning' 48 - text: string 49 - href: string 50 - share?: boolean 51 - } 52 - 53 46 export type Modal = 54 47 // Account 55 48 | DeleteAccountModal ··· 66 59 // Bluesky access 67 60 | WaitlistModal 68 61 | InviteCodesModal 69 - 70 - // Generic 71 - | LinkWarningModal 72 62 73 63 const ModalContext = React.createContext<{ 74 64 isModalActive: boolean
-180
src/view/com/modals/LinkWarning.tsx
··· 1 - import React from 'react' 2 - import {SafeAreaView, StyleSheet, View} from 'react-native' 3 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import {msg, Trans} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 - 7 - import {useOpenLink} from '#/lib/hooks/useOpenLink' 8 - import {usePalette} from '#/lib/hooks/usePalette' 9 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 10 - import {shareUrl} from '#/lib/sharing' 11 - import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers' 12 - import {colors, s} from '#/lib/styles' 13 - import {isWeb} from '#/platform/detection' 14 - import {useModalControls} from '#/state/modals' 15 - import {Button} from '#/view/com/util/forms/Button' 16 - import {Text} from '#/view/com/util/text/Text' 17 - import {ScrollView} from './util' 18 - 19 - export const snapPoints = ['50%'] 20 - 21 - export function Component({ 22 - text, 23 - href, 24 - share, 25 - }: { 26 - text: string 27 - href: string 28 - share?: boolean 29 - }) { 30 - const pal = usePalette('default') 31 - const {closeModal} = useModalControls() 32 - const {isMobile} = useWebMediaQueries() 33 - const {_} = useLingui() 34 - const potentiallyMisleading = isPossiblyAUrl(text) 35 - const openLink = useOpenLink() 36 - 37 - const onPressVisit = () => { 38 - closeModal() 39 - if (share) { 40 - shareUrl(href) 41 - } else { 42 - openLink(href, false, true) 43 - } 44 - } 45 - 46 - return ( 47 - <SafeAreaView style={[s.flex1, pal.view]}> 48 - <ScrollView 49 - testID="linkWarningModal" 50 - style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> 51 - <View style={styles.titleSection}> 52 - {potentiallyMisleading ? ( 53 - <> 54 - <FontAwesomeIcon 55 - icon="circle-exclamation" 56 - color={pal.colors.text} 57 - size={18} 58 - /> 59 - <Text type="title-lg" style={[pal.text, styles.title]}> 60 - <Trans>Potentially Misleading Link</Trans> 61 - </Text> 62 - </> 63 - ) : ( 64 - <Text type="title-lg" style={[pal.text, styles.title]}> 65 - <Trans>Leaving Bluesky</Trans> 66 - </Text> 67 - )} 68 - </View> 69 - 70 - <View style={{gap: 10}}> 71 - <Text type="lg" style={pal.text}> 72 - <Trans>This link is taking you to the following website:</Trans> 73 - </Text> 74 - 75 - <LinkBox href={href} /> 76 - 77 - {potentiallyMisleading && ( 78 - <Text type="lg" style={pal.text}> 79 - <Trans>Make sure this is where you intend to go!</Trans> 80 - </Text> 81 - )} 82 - </View> 83 - 84 - <View style={[styles.btnContainer, isMobile && {paddingBottom: 40}]}> 85 - <Button 86 - testID="confirmBtn" 87 - type="primary" 88 - onPress={onPressVisit} 89 - accessibilityLabel={share ? _(msg`Share Link`) : _(msg`Visit Site`)} 90 - accessibilityHint={ 91 - share 92 - ? _(msg`Shares the linked website`) 93 - : _(msg`Opens the linked website`) 94 - } 95 - label={share ? _(msg`Share Link`) : _(msg`Visit Site`)} 96 - labelContainerStyle={{justifyContent: 'center', padding: 4}} 97 - labelStyle={[s.f18]} 98 - /> 99 - <Button 100 - testID="cancelBtn" 101 - type="default" 102 - onPress={() => { 103 - closeModal() 104 - }} 105 - accessibilityLabel={_(msg`Cancel`)} 106 - accessibilityHint={_(msg`Cancels opening the linked website`)} 107 - label={_(msg`Cancel`)} 108 - labelContainerStyle={{justifyContent: 'center', padding: 4}} 109 - labelStyle={[s.f18]} 110 - /> 111 - </View> 112 - </ScrollView> 113 - </SafeAreaView> 114 - ) 115 - } 116 - 117 - function LinkBox({href}: {href: string}) { 118 - const pal = usePalette('default') 119 - const [scheme, hostname, rest] = React.useMemo(() => { 120 - try { 121 - const urlp = new URL(href) 122 - const [subdomain, apexdomain] = splitApexDomain(urlp.hostname) 123 - return [ 124 - urlp.protocol + '//' + subdomain, 125 - apexdomain, 126 - urlp.pathname + urlp.search + urlp.hash, 127 - ] 128 - } catch { 129 - return ['', href, ''] 130 - } 131 - }, [href]) 132 - return ( 133 - <View style={[pal.view, pal.border, styles.linkBox]}> 134 - <Text type="lg" style={pal.textLight}> 135 - {scheme} 136 - <Text type="lg-bold" style={pal.text}> 137 - {hostname} 138 - </Text> 139 - {rest} 140 - </Text> 141 - </View> 142 - ) 143 - } 144 - 145 - const styles = StyleSheet.create({ 146 - container: { 147 - flex: 1, 148 - paddingBottom: isWeb ? 0 : 40, 149 - }, 150 - titleSection: { 151 - flexDirection: 'row', 152 - justifyContent: 'center', 153 - alignItems: 'center', 154 - gap: 6, 155 - paddingTop: isWeb ? 0 : 4, 156 - paddingBottom: isWeb ? 14 : 10, 157 - }, 158 - title: { 159 - textAlign: 'center', 160 - fontWeight: '600', 161 - }, 162 - linkBox: { 163 - paddingHorizontal: 12, 164 - paddingVertical: 10, 165 - borderRadius: 6, 166 - borderWidth: 1, 167 - }, 168 - btn: { 169 - flexDirection: 'row', 170 - alignItems: 'center', 171 - justifyContent: 'center', 172 - borderRadius: 32, 173 - padding: 14, 174 - backgroundColor: colors.blue3, 175 - }, 176 - btnContainer: { 177 - paddingTop: 20, 178 - gap: 6, 179 - }, 180 - })
-4
src/view/com/modals/Modal.tsx
··· 13 13 import * as InviteCodesModal from './InviteCodes' 14 14 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 15 15 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 16 - import * as LinkWarningModal from './LinkWarning' 17 16 import * as UserAddRemoveListsModal from './UserAddRemoveLists' 18 17 19 18 const DEFAULT_SNAPPOINTS = ['90%'] ··· 68 67 } else if (activeModal?.name === 'change-password') { 69 68 snapPoints = ChangePasswordModal.snapPoints 70 69 element = <ChangePasswordModal.Component /> 71 - } else if (activeModal?.name === 'link-warning') { 72 - snapPoints = LinkWarningModal.snapPoints 73 - element = <LinkWarningModal.Component {...activeModal} /> 74 70 } else { 75 71 return null 76 72 }
-3
src/view/com/modals/Modal.web.tsx
··· 12 12 import * as InviteCodesModal from './InviteCodes' 13 13 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 14 14 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 15 - import * as LinkWarningModal from './LinkWarning' 16 15 import * as UserAddRemoveLists from './UserAddRemoveLists' 17 16 18 17 export function ModalsContainer() { ··· 65 64 element = <PostLanguagesSettingsModal.Component /> 66 65 } else if (modal.name === 'change-password') { 67 66 element = <ChangePasswordModal.Component /> 68 - } else if (modal.name === 'link-warning') { 69 - element = <LinkWarningModal.Component {...modal} /> 70 67 } else { 71 68 return null 72 69 }
+6 -5
src/view/com/util/Link.tsx
··· 30 30 import {useModalControls} from '#/state/modals' 31 31 import {WebAuxClickWrapper} from '#/view/com/util/WebAuxClickWrapper' 32 32 import {useTheme} from '#/alf' 33 + import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 33 34 import {router} from '../../../routes' 34 35 import {PressableWithHover} from './PressableWithHover' 35 36 import {Text} from './text/Text' ··· 189 190 onBeforePress?: () => void 190 191 } & TextProps) { 191 192 const navigation = useNavigationDeduped() 192 - const {openModal, closeModal} = useModalControls() 193 + const {closeModal} = useModalControls() 194 + const {linkWarningDialogControl} = useGlobalDialogsControlContext() 193 195 const openLink = useOpenLink() 194 196 195 197 if (!disableMismatchWarning && typeof text !== 'string') { ··· 211 213 linkRequiresWarning(href, typeof text === 'string' ? text : '') 212 214 if (requiresWarning) { 213 215 e?.preventDefault?.() 214 - openModal({ 215 - name: 'link-warning', 216 - text: typeof text === 'string' ? text : '', 216 + linkWarningDialogControl.open({ 217 + displayText: typeof text === 'string' ? text : '', 217 218 href, 218 219 }) 219 220 } ··· 245 246 onBeforePress, 246 247 onPressProp, 247 248 closeModal, 248 - openModal, 249 249 navigation, 250 250 href, 251 251 text, 252 252 disableMismatchWarning, 253 253 navigationAction, 254 254 openLink, 255 + linkWarningDialogControl, 255 256 ], 256 257 ) 257 258 const hrefAttrs = useMemo(() => {
+2
src/view/shell/index.tsx
··· 27 27 import {setSystemUITheme} from '#/alf/util/systemUI' 28 28 import {EmailDialog} from '#/components/dialogs/EmailDialog' 29 29 import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' 30 + import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' 30 31 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' 31 32 import {SigninDialog} from '#/components/dialogs/Signin' 32 33 import {Outlet as PortalOutlet} from '#/components/Portal' ··· 155 156 <SigninDialog /> 156 157 <EmailDialog /> 157 158 <InAppBrowserConsentDialog /> 159 + <LinkWarningDialog /> 158 160 <Lightbox /> 159 161 <PortalOutlet /> 160 162 <BottomSheetOutlet />
+2
src/view/shell/index.web.tsx
··· 18 18 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 19 19 import {atoms as a, select, useTheme} from '#/alf' 20 20 import {EmailDialog} from '#/components/dialogs/EmailDialog' 21 + import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' 21 22 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' 22 23 import {SigninDialog} from '#/components/dialogs/Signin' 23 24 import {Outlet as PortalOutlet} from '#/components/Portal' ··· 69 70 <MutedWordsDialog /> 70 71 <SigninDialog /> 71 72 <EmailDialog /> 73 + <LinkWarningDialog /> 72 74 <Lightbox /> 73 75 <PortalOutlet /> 74 76