diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-10-02 14:47:39 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-10-02 14:47:39 -0700 |
commit | fd5bbb27699942f7d741d074eafdf16bfc9ecdd6 (patch) | |
tree | a7e7e6f1e7b07fc45a4988504e2509db97689079 /src | |
parent | 2f157c152a59dc8bda3d4409204d850c2ac256a1 (diff) | |
download | voidsky-fd5bbb27699942f7d741d074eafdf16bfc9ecdd6.tar.zst |
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>
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/strings/url-helpers.ts | 51 | ||||
-rw-r--r-- | src/state/models/ui/shell.ts | 7 | ||||
-rw-r--r-- | src/view/com/modals/LinkWarning.tsx | 162 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 3 | ||||
-rw-r--r-- | src/view/com/util/Link.tsx | 25 | ||||
-rw-r--r-- | src/view/com/util/text/RichText.tsx | 1 |
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 { |