about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/strings/url-helpers.ts51
-rw-r--r--src/state/models/ui/shell.ts7
-rw-r--r--src/view/com/modals/LinkWarning.tsx162
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/util/Link.tsx25
-rw-r--r--src/view/com/util/text/RichText.tsx1
7 files changed, 251 insertions, 2 deletions
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 671dc9781..3c27d8639 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 bd285c8cd..a8937b84c 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 000000000..67a156af4
--- /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 (
+    <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,
+  },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index cd2d2d9e9..4f3f424a3 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 = <SwitchAccountModal.Component />
+  } else if (activeModal?.name === 'link-warning') {
+    snapPoints = LinkWarningModal.snapPoints
+    element = <LinkWarningModal.Component {...activeModal} />
   } else {
     return null
   }
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 685d9abe1..ee778d17d 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 = <VerifyEmailModal.Component {...modal} />
   } else if (modal.name === 'change-email') {
     element = <ChangeEmailModal.Component />
+  } else if (modal.name === 'link-warning') {
+    element = <LinkWarningModal.Component {...modal} />
   } else {
     return null
   }
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index d11bae6ba..6915d3e08 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<NavigationProp>()
 
+  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 0dc13fd34..34c201829 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 {