Modernise link warning dialog (#8243)

* add link warning dialog

* add copy for if sharing

* delete old modal

* get web working
This commit is contained in:
Samuel Newman
2025-06-18 16:17:54 +03:00
committed by GitHub
parent dd86402763
commit 4da86e5864
10 changed files with 200 additions and 213 deletions
+17 -11
View File
@@ -24,6 +24,7 @@ import {Button, type ButtonProps} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text, type TextProps} from '#/components/Typography'
import {router} from '#/routes'
import {useGlobalDialogsControlContext} from './dialogs/Context'
/**
* Only available within a `Link`, since that inherits from `Button`.
@@ -111,7 +112,8 @@ export function useLink({
}
const isExternal = isExternalUrl(href)
const {openModal, closeModal} = useModalControls()
const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const openLink = useOpenLink()
const onPress = React.useCallback(
@@ -132,10 +134,9 @@ export function useLink({
}
if (requiresWarning) {
openModal({
name: 'link-warning',
text: displayText,
href: href,
linkWarningDialogControl.open({
displayText,
href,
})
} else {
if (isExternal) {
@@ -176,13 +177,13 @@ export function useLink({
displayText,
isExternal,
href,
openModal,
openLink,
closeModal,
action,
navigation,
overridePresentation,
shouldProxy,
linkWarningDialogControl,
],
)
@@ -195,16 +196,21 @@ export function useLink({
)
if (requiresWarning) {
openModal({
name: 'link-warning',
text: displayText,
href: href,
linkWarningDialogControl.open({
displayText,
href,
share: true,
})
} else {
shareUrl(href)
}
}, [disableMismatchWarning, displayText, href, isExternal, openModal])
}, [
disableMismatchWarning,
displayText,
href,
isExternal,
linkWarningDialogControl,
])
const onLongPress = React.useCallback(
(e: GestureResponderEvent) => {
+12
View File
@@ -17,6 +17,11 @@ type ControlsContext = {
signinDialogControl: Control
inAppBrowserConsentControl: StatefulControl<string>
emailDialogControl: StatefulControl<Screen>
linkWarningDialogControl: StatefulControl<{
href: string
displayText: string
share?: boolean
}>
}
const ControlsContext = createContext<ControlsContext | null>(null)
@@ -36,6 +41,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const signinDialogControl = Dialog.useDialogControl()
const inAppBrowserConsentControl = useStatefulDialogControl<string>()
const emailDialogControl = useStatefulDialogControl<Screen>()
const linkWarningDialogControl = useStatefulDialogControl<{
href: string
displayText: string
share?: boolean
}>()
const ctx = useMemo<ControlsContext>(
() => ({
@@ -43,12 +53,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
signinDialogControl,
inAppBrowserConsentControl,
emailDialogControl,
linkWarningDialogControl,
}),
[
mutedWordsDialogControl,
signinDialogControl,
inAppBrowserConsentControl,
emailDialogControl,
linkWarningDialogControl,
],
)
+161
View File
@@ -0,0 +1,161 @@
import {useCallback, useMemo} 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 {shareUrl} from '#/lib/sharing'
import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {useGlobalDialogsControlContext} from './Context'
export function LinkWarningDialog() {
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
return (
<Dialog.Outer
control={linkWarningDialogControl.control}
nativeOptions={{preventExpansion: true}}
webOptions={{alignCenter: true}}
onClose={linkWarningDialogControl.clear}>
<Dialog.Handle />
<InAppBrowserConsentInner link={linkWarningDialogControl.value} />
</Dialog.Outer>
)
}
function InAppBrowserConsentInner({
link,
}: {
link?: {href: string; displayText: string; share?: boolean}
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const t = useTheme()
const openLink = useOpenLink()
const {gtMobile} = useBreakpoints()
const potentiallyMisleading = useMemo(
() => link && isPossiblyAUrl(link.displayText),
[link],
)
const onPressVisit = useCallback(() => {
control.close(() => {
if (!link) return
if (link.share) {
shareUrl(link.href)
} else {
openLink(link.href, undefined, true)
}
})
}, [control, link, openLink])
const onCancel = useCallback(() => {
control.close()
}, [control])
return (
<Dialog.ScrollableInner
style={web({maxWidth: 450})}
label={
potentiallyMisleading
? _(msg`Potentially misleading link warning`)
: _(msg`Leaving Bluesky`)
}>
<View style={[a.gap_2xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{potentiallyMisleading ? (
<Trans>Potentially misleading link</Trans>
) : (
<Trans>Leaving Bluesky</Trans>
)}
</Text>
<Text style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}>
<Trans>This link is taking you to the following website:</Trans>
</Text>
{link && <LinkBox href={link.href} />}
{potentiallyMisleading && (
<Text
style={[t.atoms.text_contrast_high, a.text_md, a.leading_snug]}>
<Trans>Make sure this is where you intend to go!</Trans>
</Text>
)}
</View>
<View
style={[
a.flex_1,
a.gap_sm,
gtMobile && [a.flex_row_reverse, a.justify_start],
]}>
<Button
label={link?.share ? _(msg`Share link`) : _(msg`Visit site`)}
accessibilityHint={_(msg`Opens link ${link?.href ?? ''}`)}
onPress={onPressVisit}
size="large"
variant="solid"
color={potentiallyMisleading ? 'secondary_inverted' : 'primary'}>
<ButtonText>
{link?.share ? (
<Trans>Share link</Trans>
) : (
<Trans>Visit site</Trans>
)}
</ButtonText>
</Button>
<Button
label={_(msg`Go back`)}
onPress={onCancel}
size="large"
variant="ghost"
color="secondary">
<ButtonText>
<Trans>Go back</Trans>
</ButtonText>
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function LinkBox({href}: {href: string}) {
const t = useTheme()
const [scheme, hostname, rest] = useMemo(() => {
try {
const urlp = new URL(href)
const [subdomain, apexdomain] = splitApexDomain(urlp.hostname)
return [
urlp.protocol + '//' + subdomain,
apexdomain,
urlp.pathname.replace(/\/$/, '') + urlp.search + urlp.hash,
]
} catch {
return ['', href, '']
}
}, [href])
return (
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_medium,
a.px_md,
{paddingVertical: 10},
a.rounded_sm,
a.border,
]}>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
{scheme}
<Text style={[a.text_md, a.leading_snug, t.atoms.text, a.font_bold]}>
{hostname}
</Text>
{rest}
</Text>
</View>
)
}
-10
View File
@@ -43,13 +43,6 @@ export interface ChangePasswordModal {
name: 'change-password'
}
export interface LinkWarningModal {
name: 'link-warning'
text: string
href: string
share?: boolean
}
export type Modal =
// Account
| DeleteAccountModal
@@ -67,9 +60,6 @@ export type Modal =
| WaitlistModal
| InviteCodesModal
// Generic
| LinkWarningModal
const ModalContext = React.createContext<{
isModalActive: boolean
activeModals: Modal[]
-180
View File
@@ -1,180 +0,0 @@
import React from 'react'
import {SafeAreaView, StyleSheet, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {shareUrl} from '#/lib/sharing'
import {isPossiblyAUrl, splitApexDomain} from '#/lib/strings/url-helpers'
import {colors, s} from '#/lib/styles'
import {isWeb} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {Button} from '#/view/com/util/forms/Button'
import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from './util'
export const snapPoints = ['50%']
export function Component({
text,
href,
share,
}: {
text: string
href: string
share?: boolean
}) {
const pal = usePalette('default')
const {closeModal} = useModalControls()
const {isMobile} = useWebMediaQueries()
const {_} = useLingui()
const potentiallyMisleading = isPossiblyAUrl(text)
const openLink = useOpenLink()
const onPressVisit = () => {
closeModal()
if (share) {
shareUrl(href)
} else {
openLink(href, false, true)
}
}
return (
<SafeAreaView style={[s.flex1, pal.view]}>
<ScrollView
testID="linkWarningModal"
style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
<View style={styles.titleSection}>
{potentiallyMisleading ? (
<>
<FontAwesomeIcon
icon="circle-exclamation"
color={pal.colors.text}
size={18}
/>
<Text type="title-lg" style={[pal.text, styles.title]}>
<Trans>Potentially Misleading Link</Trans>
</Text>
</>
) : (
<Text type="title-lg" style={[pal.text, styles.title]}>
<Trans>Leaving Bluesky</Trans>
</Text>
)}
</View>
<View style={{gap: 10}}>
<Text type="lg" style={pal.text}>
<Trans>This link is taking you to the following website:</Trans>
</Text>
<LinkBox href={href} />
{potentiallyMisleading && (
<Text type="lg" style={pal.text}>
<Trans>Make sure this is where you intend to go!</Trans>
</Text>
)}
</View>
<View style={[styles.btnContainer, isMobile && {paddingBottom: 40}]}>
<Button
testID="confirmBtn"
type="primary"
onPress={onPressVisit}
accessibilityLabel={share ? _(msg`Share Link`) : _(msg`Visit Site`)}
accessibilityHint={
share
? _(msg`Shares the linked website`)
: _(msg`Opens the linked website`)
}
label={share ? _(msg`Share Link`) : _(msg`Visit Site`)}
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
<Button
testID="cancelBtn"
type="default"
onPress={() => {
closeModal()
}}
accessibilityLabel={_(msg`Cancel`)}
accessibilityHint={_(msg`Cancels opening the linked website`)}
label={_(msg`Cancel`)}
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
</View>
</ScrollView>
</SafeAreaView>
)
}
function LinkBox({href}: {href: string}) {
const pal = usePalette('default')
const [scheme, hostname, rest] = React.useMemo(() => {
try {
const urlp = new URL(href)
const [subdomain, apexdomain] = splitApexDomain(urlp.hostname)
return [
urlp.protocol + '//' + subdomain,
apexdomain,
urlp.pathname + urlp.search + urlp.hash,
]
} catch {
return ['', href, '']
}
}, [href])
return (
<View style={[pal.view, pal.border, styles.linkBox]}>
<Text type="lg" style={pal.textLight}>
{scheme}
<Text type="lg-bold" style={pal.text}>
{hostname}
</Text>
{rest}
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isWeb ? 0 : 40,
},
titleSection: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 6,
paddingTop: isWeb ? 0 : 4,
paddingBottom: isWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
},
linkBox: {
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 6,
borderWidth: 1,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 20,
gap: 6,
},
})
-4
View File
@@ -13,7 +13,6 @@ import * as DeleteAccountModal from './DeleteAccount'
import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as LinkWarningModal from './LinkWarning'
import * as UserAddRemoveListsModal from './UserAddRemoveLists'
const DEFAULT_SNAPPOINTS = ['90%']
@@ -68,9 +67,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'change-password') {
snapPoints = ChangePasswordModal.snapPoints
element = <ChangePasswordModal.Component />
} else if (activeModal?.name === 'link-warning') {
snapPoints = LinkWarningModal.snapPoints
element = <LinkWarningModal.Component {...activeModal} />
} else {
return null
}
-3
View File
@@ -12,7 +12,6 @@ import * as DeleteAccountModal from './DeleteAccount'
import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as LinkWarningModal from './LinkWarning'
import * as UserAddRemoveLists from './UserAddRemoveLists'
export function ModalsContainer() {
@@ -65,8 +64,6 @@ function Modal({modal}: {modal: ModalIface}) {
element = <PostLanguagesSettingsModal.Component />
} else if (modal.name === 'change-password') {
element = <ChangePasswordModal.Component />
} else if (modal.name === 'link-warning') {
element = <LinkWarningModal.Component {...modal} />
} else {
return null
}
+6 -5
View File
@@ -30,6 +30,7 @@ import {emitSoftReset} from '#/state/events'
import {useModalControls} from '#/state/modals'
import {WebAuxClickWrapper} from '#/view/com/util/WebAuxClickWrapper'
import {useTheme} from '#/alf'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {router} from '../../../routes'
import {PressableWithHover} from './PressableWithHover'
import {Text} from './text/Text'
@@ -189,7 +190,8 @@ export const TextLink = memo(function TextLink({
onBeforePress?: () => void
} & TextProps) {
const navigation = useNavigationDeduped()
const {openModal, closeModal} = useModalControls()
const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const openLink = useOpenLink()
if (!disableMismatchWarning && typeof text !== 'string') {
@@ -211,9 +213,8 @@ export const TextLink = memo(function TextLink({
linkRequiresWarning(href, typeof text === 'string' ? text : '')
if (requiresWarning) {
e?.preventDefault?.()
openModal({
name: 'link-warning',
text: typeof text === 'string' ? text : '',
linkWarningDialogControl.open({
displayText: typeof text === 'string' ? text : '',
href,
})
}
@@ -245,13 +246,13 @@ export const TextLink = memo(function TextLink({
onBeforePress,
onPressProp,
closeModal,
openModal,
navigation,
href,
text,
disableMismatchWarning,
navigationAction,
openLink,
linkWarningDialogControl,
],
)
const hrefAttrs = useMemo(() => {
+2
View File
@@ -27,6 +27,7 @@ import {atoms as a, select, useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal'
@@ -155,6 +156,7 @@ function ShellInner() {
<SigninDialog />
<EmailDialog />
<InAppBrowserConsentDialog />
<LinkWarningDialog />
<Lightbox />
<PortalOutlet />
<BottomSheetOutlet />
+2
View File
@@ -18,6 +18,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, select, useTheme} from '#/alf'
import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal'
@@ -69,6 +70,7 @@ function ShellInner() {
<MutedWordsDialog />
<SigninDialog />
<EmailDialog />
<LinkWarningDialog />
<Lightbox />
<PortalOutlet />