Warn the user on links that dont match their text (#1573)
* Add link warning modal when URLs do not match their text * Simplify the misleading link case for clarity * Fix typecheck * fix dark mode * Give a stronger visual indication of the root domain in the link warning * More rigorous URL mismatch logic * Remove debug --------- Co-authored-by: Ansh Nanda <anshnanda10@gmail.com>zio/stable
parent
2f157c152a
commit
fd5bbb2769
|
@ -0,0 +1,98 @@
|
||||||
|
import {
|
||||||
|
linkRequiresWarning,
|
||||||
|
isPossiblyAUrl,
|
||||||
|
splitApexDomain,
|
||||||
|
} from '../../../src/lib/strings/url-helpers'
|
||||||
|
|
||||||
|
describe('linkRequiresWarning', () => {
|
||||||
|
type Case = [string, string, boolean]
|
||||||
|
const cases: Case[] = [
|
||||||
|
['http://example.com', 'http://example.com', false],
|
||||||
|
['http://example.com', 'example.com', false],
|
||||||
|
['http://example.com', 'example.com/page', false],
|
||||||
|
['http://example.com', '', true],
|
||||||
|
['http://example.com', 'other.com', true],
|
||||||
|
['http://example.com', 'http://other.com', true],
|
||||||
|
['http://example.com', 'some label', true],
|
||||||
|
['http://example.com', 'example.com more', true],
|
||||||
|
['http://example.com', 'http://example.co', true],
|
||||||
|
['http://example.co', 'http://example.com', true],
|
||||||
|
['http://example.com', 'example.co', true],
|
||||||
|
['http://example.co', 'example.com', true],
|
||||||
|
['http://site.pages.dev', 'http://site.page', true],
|
||||||
|
['http://site.page', 'http://site.pages.dev', true],
|
||||||
|
['http://site.pages.dev', 'site.page', true],
|
||||||
|
['http://site.page', 'site.pages.dev', true],
|
||||||
|
['http://site.pages.dev', 'http://site.pages', true],
|
||||||
|
['http://site.pages', 'http://site.pages.dev', true],
|
||||||
|
['http://site.pages.dev', 'site.pages', true],
|
||||||
|
['http://site.pages', 'site.pages.dev', true],
|
||||||
|
|
||||||
|
// bad uri inputs, default to true
|
||||||
|
['', '', true],
|
||||||
|
['example.com', 'example.com', true],
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'given input uri %p and text %p, returns %p',
|
||||||
|
(uri, text, expected) => {
|
||||||
|
const output = linkRequiresWarning(uri, text)
|
||||||
|
expect(output).toEqual(expected)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isPossiblyAUrl', () => {
|
||||||
|
type Case = [string, boolean]
|
||||||
|
const cases: Case[] = [
|
||||||
|
['', false],
|
||||||
|
['text', false],
|
||||||
|
['some text', false],
|
||||||
|
['some text', false],
|
||||||
|
['some domain.com', false],
|
||||||
|
['domain.com', true],
|
||||||
|
[' domain.com', true],
|
||||||
|
['domain.com ', true],
|
||||||
|
[' domain.com ', true],
|
||||||
|
['http://domain.com', true],
|
||||||
|
[' http://domain.com', true],
|
||||||
|
['http://domain.com ', true],
|
||||||
|
[' http://domain.com ', true],
|
||||||
|
['https://domain.com', true],
|
||||||
|
[' https://domain.com', true],
|
||||||
|
['https://domain.com ', true],
|
||||||
|
[' https://domain.com ', true],
|
||||||
|
['http://domain.com/foo', true],
|
||||||
|
['http://domain.com stuff', true],
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(cases)('given input uri %p, returns %p', (str, expected) => {
|
||||||
|
const output = isPossiblyAUrl(str)
|
||||||
|
expect(output).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('splitApexDomain', () => {
|
||||||
|
type Case = [string, string, string]
|
||||||
|
const cases: Case[] = [
|
||||||
|
['', '', ''],
|
||||||
|
['example.com', '', 'example.com'],
|
||||||
|
['foo.example.com', 'foo.', 'example.com'],
|
||||||
|
['foo.bar.example.com', 'foo.bar.', 'example.com'],
|
||||||
|
['example.co.uk', '', 'example.co.uk'],
|
||||||
|
['foo.example.co.uk', 'foo.', 'example.co.uk'],
|
||||||
|
['example.nonsense', '', 'example.nonsense'],
|
||||||
|
['foo.example.nonsense', '', 'foo.example.nonsense'],
|
||||||
|
['foo.bar.example.nonsense', '', 'foo.bar.example.nonsense'],
|
||||||
|
['example.com.example.com', 'example.com.', 'example.com'],
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(cases)(
|
||||||
|
'given input uri %p, returns %p,%p',
|
||||||
|
(str, expected1, expected2) => {
|
||||||
|
const output = splitApexDomain(str)
|
||||||
|
expect(output[0]).toEqual(expected1)
|
||||||
|
expect(output[1]).toEqual(expected2)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
|
@ -115,6 +115,7 @@
|
||||||
"normalize-url": "^8.0.0",
|
"normalize-url": "^8.0.0",
|
||||||
"patch-package": "^6.5.1",
|
"patch-package": "^6.5.1",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
|
"psl": "^1.9.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-avatar-editor": "^13.0.0",
|
"react-avatar-editor": "^13.0.0",
|
||||||
"react-circular-progressbar": "^2.1.0",
|
"react-circular-progressbar": "^2.1.0",
|
||||||
|
@ -175,6 +176,7 @@
|
||||||
"@types/lodash.samplesize": "^4.2.7",
|
"@types/lodash.samplesize": "^4.2.7",
|
||||||
"@types/lodash.set": "^4.3.7",
|
"@types/lodash.set": "^4.3.7",
|
||||||
"@types/lodash.shuffle": "^4.2.7",
|
"@types/lodash.shuffle": "^4.2.7",
|
||||||
|
"@types/psl": "^1.1.1",
|
||||||
"@types/react-avatar-editor": "^13.0.0",
|
"@types/react-avatar-editor": "^13.0.0",
|
||||||
"@types/react-responsive": "^8.0.5",
|
"@types/react-responsive": "^8.0.5",
|
||||||
"@types/react-test-renderer": "^17.0.1",
|
"@types/react-test-renderer": "^17.0.1",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
import {PROD_SERVICE} from 'state/index'
|
import {PROD_SERVICE} from 'state/index'
|
||||||
import TLDs from 'tlds'
|
import TLDs from 'tlds'
|
||||||
|
import psl from 'psl'
|
||||||
|
|
||||||
export function isValidDomain(str: string): boolean {
|
export function isValidDomain(str: string): boolean {
|
||||||
return !!TLDs.find(tld => {
|
return !!TLDs.find(tld => {
|
||||||
|
@ -166,3 +167,53 @@ export function getYoutubeVideoId(link: string): string | undefined {
|
||||||
}
|
}
|
||||||
return videoId
|
return videoId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function linkRequiresWarning(uri: string, label: string) {
|
||||||
|
const labelDomain = labelToDomain(label)
|
||||||
|
if (!labelDomain) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const urip = new URL(uri)
|
||||||
|
return labelDomain !== urip.hostname
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelToDomain(label: string): string | undefined {
|
||||||
|
// any spaces just immediately consider the label a non-url
|
||||||
|
if (/\s/.test(label)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(label).hostname
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
return new URL('https://' + label).hostname
|
||||||
|
} catch {}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPossiblyAUrl(str: string): boolean {
|
||||||
|
str = str.trim()
|
||||||
|
if (str.startsWith('http://')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (str.startsWith('https://')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const [firstWord] = str.split(/[\s\/]/)
|
||||||
|
return isValidDomain(firstWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitApexDomain(hostname: string): [string, string] {
|
||||||
|
const hostnamep = psl.parse(hostname)
|
||||||
|
if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
|
||||||
|
return ['', hostname]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
hostnamep.subdomain ? `${hostnamep.subdomain}.` : '',
|
||||||
|
hostnamep.domain,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
|
@ -154,6 +154,12 @@ export interface SwitchAccountModal {
|
||||||
name: 'switch-account'
|
name: 'switch-account'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkWarningModal {
|
||||||
|
name: 'link-warning'
|
||||||
|
text: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
export type Modal =
|
export type Modal =
|
||||||
// Account
|
// Account
|
||||||
| AddAppPasswordModal
|
| AddAppPasswordModal
|
||||||
|
@ -191,6 +197,7 @@ export type Modal =
|
||||||
|
|
||||||
// Generic
|
// Generic
|
||||||
| ConfirmModal
|
| ConfirmModal
|
||||||
|
| LinkWarningModal
|
||||||
|
|
||||||
interface LightboxModel {}
|
interface LightboxModel {}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,162 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {Linking, SafeAreaView, StyleSheet, View} from 'react-native'
|
||||||
|
import {ScrollView} from './util'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {Button} from '../util/forms/Button'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {s, colors} from 'lib/styles'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers'
|
||||||
|
|
||||||
|
export const snapPoints = ['50%']
|
||||||
|
|
||||||
|
export const Component = observer(function Component({
|
||||||
|
text,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
text: string
|
||||||
|
href: string
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
const potentiallyMisleading = isPossiblyAUrl(text)
|
||||||
|
|
||||||
|
const onPressVisit = () => {
|
||||||
|
store.shell.closeModal()
|
||||||
|
Linking.openURL(href)
|
||||||
|
}
|
||||||
|
|
||||||
|
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]}>
|
||||||
|
Potentially Misleading Link
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||||
|
Leaving Bluesky
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{gap: 10}}>
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
This link is taking you to the following website:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<LinkBox href={href} />
|
||||||
|
|
||||||
|
{potentiallyMisleading && (
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
Make sure this is where you intend to go!
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.btnContainer, isMobile && {paddingBottom: 40}]}>
|
||||||
|
<Button
|
||||||
|
testID="confirmBtn"
|
||||||
|
type="primary"
|
||||||
|
onPress={onPressVisit}
|
||||||
|
accessibilityLabel="Visit Site"
|
||||||
|
accessibilityHint=""
|
||||||
|
label="Visit Site"
|
||||||
|
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||||
|
labelStyle={[s.f18]}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
testID="cancelBtn"
|
||||||
|
type="default"
|
||||||
|
onPress={() => store.shell.closeModal()}
|
||||||
|
accessibilityLabel="Cancel"
|
||||||
|
accessibilityHint=""
|
||||||
|
label="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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -33,6 +33,7 @@ import * as BirthDateSettingsModal from './BirthDateSettings'
|
||||||
import * as VerifyEmailModal from './VerifyEmail'
|
import * as VerifyEmailModal from './VerifyEmail'
|
||||||
import * as ChangeEmailModal from './ChangeEmail'
|
import * as ChangeEmailModal from './ChangeEmail'
|
||||||
import * as SwitchAccountModal from './SwitchAccount'
|
import * as SwitchAccountModal from './SwitchAccount'
|
||||||
|
import * as LinkWarningModal from './LinkWarning'
|
||||||
|
|
||||||
const DEFAULT_SNAPPOINTS = ['90%']
|
const DEFAULT_SNAPPOINTS = ['90%']
|
||||||
|
|
||||||
|
@ -148,6 +149,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
} else if (activeModal?.name === 'switch-account') {
|
} else if (activeModal?.name === 'switch-account') {
|
||||||
snapPoints = SwitchAccountModal.snapPoints
|
snapPoints = SwitchAccountModal.snapPoints
|
||||||
element = <SwitchAccountModal.Component />
|
element = <SwitchAccountModal.Component />
|
||||||
|
} else if (activeModal?.name === 'link-warning') {
|
||||||
|
snapPoints = LinkWarningModal.snapPoints
|
||||||
|
element = <LinkWarningModal.Component {...activeModal} />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ import * as ModerationDetailsModal from './ModerationDetails'
|
||||||
import * as BirthDateSettingsModal from './BirthDateSettings'
|
import * as BirthDateSettingsModal from './BirthDateSettings'
|
||||||
import * as VerifyEmailModal from './VerifyEmail'
|
import * as VerifyEmailModal from './VerifyEmail'
|
||||||
import * as ChangeEmailModal from './ChangeEmail'
|
import * as ChangeEmailModal from './ChangeEmail'
|
||||||
|
import * as LinkWarningModal from './LinkWarning'
|
||||||
|
|
||||||
export const ModalsContainer = observer(function ModalsContainer() {
|
export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
@ -116,6 +117,8 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||||
element = <VerifyEmailModal.Component {...modal} />
|
element = <VerifyEmailModal.Component {...modal} />
|
||||||
} else if (modal.name === 'change-email') {
|
} else if (modal.name === 'change-email') {
|
||||||
element = <ChangeEmailModal.Component />
|
element = <ChangeEmailModal.Component />
|
||||||
|
} else if (modal.name === 'link-warning') {
|
||||||
|
element = <LinkWarningModal.Component {...modal} />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,11 @@ import {TypographyVariant} from 'lib/ThemeContext'
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {router} from '../../../routes'
|
import {router} from '../../../routes'
|
||||||
import {useStores, RootStoreModel} from 'state/index'
|
import {useStores, RootStoreModel} from 'state/index'
|
||||||
import {convertBskyAppUrlIfNeeded, isExternalUrl} from 'lib/strings/url-helpers'
|
import {
|
||||||
|
convertBskyAppUrlIfNeeded,
|
||||||
|
isExternalUrl,
|
||||||
|
linkRequiresWarning,
|
||||||
|
} from 'lib/strings/url-helpers'
|
||||||
import {isAndroid} from 'platform/detection'
|
import {isAndroid} from 'platform/detection'
|
||||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||||
import {PressableWithHover} from './PressableWithHover'
|
import {PressableWithHover} from './PressableWithHover'
|
||||||
|
@ -143,6 +147,7 @@ export const TextLink = observer(function TextLink({
|
||||||
dataSet,
|
dataSet,
|
||||||
title,
|
title,
|
||||||
onPress,
|
onPress,
|
||||||
|
warnOnMismatchingLabel,
|
||||||
...orgProps
|
...orgProps
|
||||||
}: {
|
}: {
|
||||||
testID?: string
|
testID?: string
|
||||||
|
@ -154,13 +159,29 @@ export const TextLink = observer(function TextLink({
|
||||||
lineHeight?: number
|
lineHeight?: number
|
||||||
dataSet?: any
|
dataSet?: any
|
||||||
title?: string
|
title?: string
|
||||||
|
warnOnMismatchingLabel?: boolean
|
||||||
} & TextProps) {
|
} & TextProps) {
|
||||||
const {...props} = useLinkProps({to: sanitizeUrl(href)})
|
const {...props} = useLinkProps({to: sanitizeUrl(href)})
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
if (warnOnMismatchingLabel && typeof text !== 'string') {
|
||||||
|
console.error('Unable to detect mismatching label')
|
||||||
|
}
|
||||||
|
|
||||||
props.onPress = React.useCallback(
|
props.onPress = React.useCallback(
|
||||||
(e?: Event) => {
|
(e?: Event) => {
|
||||||
|
const requiresWarning =
|
||||||
|
warnOnMismatchingLabel &&
|
||||||
|
linkRequiresWarning(href, typeof text === 'string' ? text : '')
|
||||||
|
if (requiresWarning) {
|
||||||
|
e?.preventDefault?.()
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'link-warning',
|
||||||
|
text: typeof text === 'string' ? text : '',
|
||||||
|
href,
|
||||||
|
})
|
||||||
|
}
|
||||||
if (onPress) {
|
if (onPress) {
|
||||||
e?.preventDefault?.()
|
e?.preventDefault?.()
|
||||||
// @ts-ignore function signature differs by platform -prf
|
// @ts-ignore function signature differs by platform -prf
|
||||||
|
@ -168,7 +189,7 @@ export const TextLink = observer(function TextLink({
|
||||||
}
|
}
|
||||||
return onPressInner(store, navigation, sanitizeUrl(href), e)
|
return onPressInner(store, navigation, sanitizeUrl(href), e)
|
||||||
},
|
},
|
||||||
[onPress, store, navigation, href],
|
[onPress, store, navigation, href, text, warnOnMismatchingLabel],
|
||||||
)
|
)
|
||||||
const hrefAttrs = useMemo(() => {
|
const hrefAttrs = useMemo(() => {
|
||||||
const isExternal = isExternalUrl(href)
|
const isExternal = isExternalUrl(href)
|
||||||
|
|
|
@ -89,6 +89,7 @@ export function RichText({
|
||||||
href={link.uri}
|
href={link.uri}
|
||||||
style={[style, lineHeightStyle, pal.link]}
|
style={[style, lineHeightStyle, pal.link]}
|
||||||
dataSet={WORD_WRAP}
|
dataSet={WORD_WRAP}
|
||||||
|
warnOnMismatchingLabel
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -5412,6 +5412,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
|
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
|
||||||
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
|
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
|
||||||
|
|
||||||
|
"@types/psl@^1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/psl/-/psl-1.1.1.tgz#3ba9e6d4bd2a32652a639fd5df7e539151d0a3b2"
|
||||||
|
integrity sha512-nHPbucWhAfVSuJ+xVc4AjjtM/y6U/eLHeXxyjzPHzKVr+j8uHvGg2wlXjmReSE2p851ltEWKGNQOtBK0beF/Eg==
|
||||||
|
|
||||||
"@types/q@^1.5.1":
|
"@types/q@^1.5.1":
|
||||||
version "1.5.5"
|
version "1.5.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df"
|
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df"
|
||||||
|
@ -15525,7 +15530,7 @@ pseudomap@^1.0.2:
|
||||||
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
|
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
|
||||||
integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==
|
integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==
|
||||||
|
|
||||||
psl@^1.1.33:
|
psl@^1.1.33, psl@^1.9.0:
|
||||||
version "1.9.0"
|
version "1.9.0"
|
||||||
resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
|
resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
|
||||||
integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==
|
integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==
|
||||||
|
|
Loading…
Reference in New Issue