diff --git a/__tests__/lib/strings/url-helpers.test.ts b/__tests__/lib/strings/url-helpers.test.ts
new file mode 100644
index 0000000000..3055a9ef6d
--- /dev/null
+++ b/__tests__/lib/strings/url-helpers.test.ts
@@ -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)
+ },
+ )
+})
diff --git a/package.json b/package.json
index 28e9a69927..3f99aea609 100644
--- a/package.json
+++ b/package.json
@@ -115,6 +115,7 @@
"normalize-url": "^8.0.0",
"patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0",
+ "psl": "^1.9.0",
"react": "18.2.0",
"react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0",
@@ -175,6 +176,7 @@
"@types/lodash.samplesize": "^4.2.7",
"@types/lodash.set": "^4.3.7",
"@types/lodash.shuffle": "^4.2.7",
+ "@types/psl": "^1.1.1",
"@types/react-avatar-editor": "^13.0.0",
"@types/react-responsive": "^8.0.5",
"@types/react-test-renderer": "^17.0.1",
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 671dc97815..3c27d86397 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -1,6 +1,7 @@
import {AtUri} from '@atproto/api'
import {PROD_SERVICE} from 'state/index'
import TLDs from 'tlds'
+import psl from 'psl'
export function isValidDomain(str: string): boolean {
return !!TLDs.find(tld => {
@@ -166,3 +167,53 @@ export function getYoutubeVideoId(link: string): string | undefined {
}
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,
+ ]
+}
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index bd285c8cdf..a8937b84ca 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -154,6 +154,12 @@ export interface SwitchAccountModal {
name: 'switch-account'
}
+export interface LinkWarningModal {
+ name: 'link-warning'
+ text: string
+ href: string
+}
+
export type Modal =
// Account
| AddAppPasswordModal
@@ -191,6 +197,7 @@ export type Modal =
// Generic
| ConfirmModal
+ | LinkWarningModal
interface LightboxModel {}
diff --git a/src/view/com/modals/LinkWarning.tsx b/src/view/com/modals/LinkWarning.tsx
new file mode 100644
index 0000000000..67a156af41
--- /dev/null
+++ b/src/view/com/modals/LinkWarning.tsx
@@ -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 (
+
+
+
+ {potentiallyMisleading ? (
+ <>
+
+
+ Potentially Misleading Link
+
+ >
+ ) : (
+
+ Leaving Bluesky
+
+ )}
+
+
+
+
+ This link is taking you to the following website:
+
+
+
+
+ {potentiallyMisleading && (
+
+ Make sure this is where you intend to go!
+
+ )}
+
+
+
+
+
+
+
+ )
+})
+
+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 (
+
+
+ {scheme}
+
+ {hostname}
+
+ {rest}
+
+
+ )
+}
+
+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,
+ },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index cd2d2d9e9a..4f3f424a31 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -33,6 +33,7 @@ import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail'
import * as SwitchAccountModal from './SwitchAccount'
+import * as LinkWarningModal from './LinkWarning'
const DEFAULT_SNAPPOINTS = ['90%']
@@ -148,6 +149,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'switch-account') {
snapPoints = SwitchAccountModal.snapPoints
element =
+ } else if (activeModal?.name === 'link-warning') {
+ snapPoints = LinkWarningModal.snapPoints
+ element =
} else {
return null
}
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 685d9abe11..ee778d17de 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -30,6 +30,7 @@ import * as ModerationDetailsModal from './ModerationDetails'
import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail'
+import * as LinkWarningModal from './LinkWarning'
export const ModalsContainer = observer(function ModalsContainer() {
const store = useStores()
@@ -116,6 +117,8 @@ function Modal({modal}: {modal: ModalIface}) {
element =
} else if (modal.name === 'change-email') {
element =
+ } else if (modal.name === 'link-warning') {
+ element =
} else {
return null
}
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index d11bae6ba4..6915d3e083 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -23,7 +23,11 @@ import {TypographyVariant} from 'lib/ThemeContext'
import {NavigationProp} from 'lib/routes/types'
import {router} from '../../../routes'
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 {sanitizeUrl} from '@braintree/sanitize-url'
import {PressableWithHover} from './PressableWithHover'
@@ -143,6 +147,7 @@ export const TextLink = observer(function TextLink({
dataSet,
title,
onPress,
+ warnOnMismatchingLabel,
...orgProps
}: {
testID?: string
@@ -154,13 +159,29 @@ export const TextLink = observer(function TextLink({
lineHeight?: number
dataSet?: any
title?: string
+ warnOnMismatchingLabel?: boolean
} & TextProps) {
const {...props} = useLinkProps({to: sanitizeUrl(href)})
const store = useStores()
const navigation = useNavigation()
+ if (warnOnMismatchingLabel && typeof text !== 'string') {
+ console.error('Unable to detect mismatching label')
+ }
+
props.onPress = React.useCallback(
(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) {
e?.preventDefault?.()
// @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)
},
- [onPress, store, navigation, href],
+ [onPress, store, navigation, href, text, warnOnMismatchingLabel],
)
const hrefAttrs = useMemo(() => {
const isExternal = isExternalUrl(href)
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
index 0dc13fd34c..34c201829a 100644
--- a/src/view/com/util/text/RichText.tsx
+++ b/src/view/com/util/text/RichText.tsx
@@ -89,6 +89,7 @@ export function RichText({
href={link.uri}
style={[style, lineHeightStyle, pal.link]}
dataSet={WORD_WRAP}
+ warnOnMismatchingLabel
/>,
)
} else {
diff --git a/yarn.lock b/yarn.lock
index 7a81e64e07..a4c1cbb612 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5412,6 +5412,11 @@
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
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":
version "1.5.5"
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"
integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==
-psl@^1.1.33:
+psl@^1.1.33, psl@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==