diff --git a/README.md b/README.md index cdcd089..7b4d046 100644 --- a/README.md +++ b/README.md @@ -324,13 +324,13 @@ type UrlConfig = { ``` -### `webFallback` +### `useNativeSchemes` -| Type | Required | Default | Description | -| ------- | -------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| boolean | No | Android: `true`, iOS: `false` | Whether to link to web versions of services (e.g. Facebook, Instagram, Twitter) for hashtag and mention links when users don't have the respective app installed. | +| Type | Required | Default | Description | +| ------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| boolean | No | `false` | Whether to use native app schemes (e.g. `twitter://`) rather than web URLs when linking to services for hashtag and mention links. | -_Note:_ Requires `LSApplicationQueriesSchemes` on iOS so it is disabled by default on iOS. See [Linking docs](https://reactnative.dev/docs/linking.html) for more info. +_Note:_ Prior to v4, the `webFallback` prop enabled a check to see whether the user had a particular app installed using `Linking.canOpenUrl`, falling back to a web link if not. Due to permissions requirements on iOS and upcoming changes on Android, this feature was removed and instead, services will be linked to the web versions by default. Use the `useNativeSchemes` prop to enable native app linking or use the `onPress` and/or `matchers` props to provide your own custom logic for linking and opening apps. ## Custom Matchers diff --git a/src/Autolink.tsx b/src/Autolink.tsx index e70c7dd..b5a2533 100644 --- a/src/Autolink.tsx +++ b/src/Autolink.tsx @@ -16,16 +16,7 @@ import { MentionMatch, PhoneMatch, } from 'autolinker/dist/es2015'; -import { - Alert, - Linking, - Platform, - StyleSheet, - StyleProp, - Text, - TextStyle, - TextProps, -} from 'react-native'; +import { Alert, Linking, StyleSheet, StyleProp, Text, TextStyle, TextProps } from 'react-native'; import { truncate } from './truncate'; import { CustomMatch, CustomMatcher } from './CustomMatch'; import { PolymorphicComponentProps } from './types'; @@ -75,7 +66,7 @@ export interface AutolinkProps { wwwMatches?: boolean; tldMatches?: boolean; }; - webFallback?: boolean; + useNativeSchemes?: boolean; } type AutolinkComponentProps = PolymorphicComponentProps< @@ -107,26 +98,25 @@ export const Autolink = React.memo( truncateChars = '..', truncateLocation = 'smart', url = true, - // iOS requires LSApplicationQueriesSchemes for Linking.canOpenURL - webFallback = Platform.OS !== 'ios' && Platform.OS !== 'macos', + useNativeSchemes = false, ...props }: AutolinkComponentProps): JSX.Element | null => { const getUrl = useCallback( - (match: Match): string[] => { + (match: Match): string => { switch (match.getType()) { case 'email': return urls.getEmailUrl(match as EmailMatch); case 'hashtag': - return urls.getHashtagUrl(match as HashtagMatch, hashtag); + return urls.getHashtagUrl(match as HashtagMatch, hashtag, useNativeSchemes); case 'mention': - return urls.getMentionUrl(match as MentionMatch, mention); + return urls.getMentionUrl(match as MentionMatch, mention, useNativeSchemes); case 'phone': return urls.getPhoneUrl(match as PhoneMatch, phone); default: - return [match.getAnchorHref()]; + return match.getAnchorHref(); } }, - [hashtag, mention, phone], + [hashtag, mention, phone, useNativeSchemes], ); const onPress = useCallback( @@ -146,19 +136,15 @@ export const Autolink = React.memo( return; } - const [linkUrl, fallbackUrl] = getUrl(match); + const linkUrl = getUrl(match); if (onPressProp) { onPressProp(linkUrl, match); - } else if (webFallback) { - Linking.canOpenURL(linkUrl).then((supported) => { - Linking.openURL(!supported && fallbackUrl ? fallbackUrl : linkUrl); - }); } else { Linking.openURL(linkUrl); } }, - [getUrl, onPressProp, showAlert, webFallback], + [getUrl, onPressProp, showAlert], ); const onLongPress = useCallback( @@ -170,7 +156,7 @@ export const Autolink = React.memo( } if (onLongPressProp) { - const [linkUrl] = getUrl(match); + const linkUrl = getUrl(match); onLongPressProp(linkUrl, match); } }, diff --git a/src/__tests__/Autolink.test.tsx b/src/__tests__/Autolink.test.tsx index 31c9870..8c086f7 100644 --- a/src/__tests__/Autolink.test.tsx +++ b/src/__tests__/Autolink.test.tsx @@ -253,7 +253,7 @@ describe('', () => { ); tree.root.findAllByType(Text)[1].props.onPress(); expect(onPress.mock.calls.length).toBe(1); - expect(onPress.mock.calls[0][0]).toBe('instagram://tag?name=awesome'); + expect(onPress.mock.calls[0][0]).toBe('https://www.instagram.com/explore/tags/awesome/'); }); test('uses mention url when pressing linked mention', () => { @@ -263,6 +263,16 @@ describe('', () => { ); tree.root.findAllByType(Text)[1].props.onPress(); expect(onPress.mock.calls.length).toBe(1); + expect(onPress.mock.calls[0][0]).toBe('https://twitter.com/twitter'); + }); + + test('uses native scheme for mention url when enabled', () => { + const onPress = jest.fn(); + const tree = renderer.create( + , + ); + tree.root.findAllByType(Text)[1].props.onPress(); + expect(onPress.mock.calls.length).toBe(1); expect(onPress.mock.calls[0][0]).toBe('twitter://user?screen_name=twitter'); }); diff --git a/src/__tests__/urls.test.ts b/src/__tests__/urls.test.ts index 93b369c..ead5e2d 100644 --- a/src/__tests__/urls.test.ts +++ b/src/__tests__/urls.test.ts @@ -4,86 +4,96 @@ import { getEmailUrl, getHashtagUrl, getMentionUrl, getPhoneUrl } from '../urls' describe('urls', () => { describe('getEmailUrl()', () => { test('returns mailto url', () => { - expect(getEmailUrl(new EmailMatch({ email: 'test@example.com' } as any))).toEqual([ + expect(getEmailUrl(new EmailMatch({ email: 'test@example.com' } as any))).toEqual( 'mailto:test%40example.com', - ]); + ); }); }); describe('getHashtagUrl()', () => { - test('returns facebook hashtag url with fallback', () => { - expect(getHashtagUrl(new HashtagMatch({ hashtag: 'awesome' } as any), 'facebook')).toEqual([ - 'fb://hashtag/awesome', - 'https://www.facebook.com/hashtag/awesome', - ]); + test('returns facebook hashtag urls', () => { + expect( + getHashtagUrl(new HashtagMatch({ hashtag: 'awesome' } as any), 'facebook', true), + ).toEqual('fb://hashtag/awesome'); + expect( + getHashtagUrl(new HashtagMatch({ hashtag: 'awesome' } as any), 'facebook', false), + ).toEqual('https://www.facebook.com/hashtag/awesome'); }); - test('returns instagram hashtag url with fallback', () => { - expect(getHashtagUrl(new HashtagMatch({ hashtag: 'awesome' } as any), 'instagram')).toEqual([ - 'instagram://tag?name=awesome', - 'https://www.instagram.com/explore/tags/awesome/', - ]); + test('returns instagram hashtag urls', () => { + expect( + getHashtagUrl(new HashtagMatch({ hashtag: 'awesome' } as any), 'instagram', true), + ).toEqual('instagram://tag?name=awesome'); + expect( + getHashtagUrl(new HashtagMatch({ hashtag: 'awesome' } as any), 'instagram', false), + ).toEqual('https://www.instagram.com/explore/tags/awesome/'); }); - test('returns twitter hashtag url with fallback', () => { - expect(getHashtagUrl(new HashtagMatch({ hashtag: 'awesome' } as any), 'twitter')).toEqual([ - 'twitter://search?query=%23awesome', - 'https://twitter.com/hashtag/awesome', - ]); + test('returns twitter hashtag urls', () => { + expect( + getHashtagUrl(new HashtagMatch({ hashtag: 'awesome' } as any), 'twitter', true), + ).toEqual('twitter://search?query=%23awesome'); + expect( + getHashtagUrl(new HashtagMatch({ hashtag: 'awesome' } as any), 'twitter', false), + ).toEqual('https://twitter.com/hashtag/awesome'); }); test('returns matched text if service does not match any supported service', () => { - expect(getHashtagUrl(new HashtagMatch({ matchedText: '#awesome' } as any), false)).toEqual([ + expect(getHashtagUrl(new HashtagMatch({ matchedText: '#awesome' } as any), false)).toEqual( '#awesome', - ]); + ); }); }); describe('getMentionUrl()', () => { - test('returns instagram mention url with fallback', () => { - expect(getMentionUrl(new MentionMatch({ mention: 'username' } as any), 'instagram')).toEqual([ - 'instagram://user?username=username', - 'https://www.instagram.com/username/', - ]); + test('returns instagram mention urls', () => { + expect( + getMentionUrl(new MentionMatch({ mention: 'username' } as any), 'instagram', true), + ).toEqual('instagram://user?username=username'); + expect( + getMentionUrl(new MentionMatch({ mention: 'username' } as any), 'instagram', false), + ).toEqual('https://www.instagram.com/username/'); }); test('returns soundcloud mention url', () => { - expect( - getMentionUrl(new MentionMatch({ mention: 'username' } as any), 'soundcloud'), - ).toEqual(['https://soundcloud.com/username']); + expect(getMentionUrl(new MentionMatch({ mention: 'username' } as any), 'soundcloud')).toEqual( + 'https://soundcloud.com/username', + ); }); - test('returns twitter mention url with fallback', () => { - expect(getMentionUrl(new MentionMatch({ mention: 'username' } as any), 'twitter')).toEqual([ - 'twitter://user?screen_name=username', - 'https://twitter.com/username', - ]); + test('returns twitter mention urls', () => { + expect( + getMentionUrl(new MentionMatch({ mention: 'username' } as any), 'twitter', true), + ).toEqual('twitter://user?screen_name=username'); + expect( + getMentionUrl(new MentionMatch({ mention: 'username' } as any), 'twitter', false), + ).toEqual('https://twitter.com/username'); }); test('returns matched text if service does not match any supported service', () => { - expect(getMentionUrl(new MentionMatch({ matchedText: '@username' } as any), false)).toEqual([ + expect(getMentionUrl(new MentionMatch({ matchedText: '@username' } as any), false)).toEqual( '@username', - ]); + ); }); }); describe('getPhoneUrl()', () => { test('returns sms/text url', () => { - expect(getPhoneUrl(new PhoneMatch({ number: '+14085550123' } as any), 'sms')).toEqual([ + expect(getPhoneUrl(new PhoneMatch({ number: '+14085550123' } as any), 'sms')).toEqual( 'sms:+14085550123', - ]); - expect(getPhoneUrl(new PhoneMatch({ number: '+14085550123' } as any), 'text')).toEqual([ + ); + expect(getPhoneUrl(new PhoneMatch({ number: '+14085550123' } as any), 'text')).toEqual( 'sms:+14085550123', - ]); + ); }); test('returns tel url by default', () => { - expect(getPhoneUrl(new PhoneMatch({ number: '+14085550123' } as any), 'tel')).toEqual([ + expect(getPhoneUrl(new PhoneMatch({ number: '+14085550123' } as any), 'tel')).toEqual( 'tel:+14085550123', - ]); - expect(getPhoneUrl(new PhoneMatch({ number: '+14085550123' } as any))).toEqual([ + ); + expect(getPhoneUrl(new PhoneMatch({ number: '+14085550123' } as any))).toEqual( 'tel:+14085550123', - ]); + ); }); }); }); diff --git a/src/urls.ts b/src/urls.ts index 973c361..736a808 100644 --- a/src/urls.ts +++ b/src/urls.ts @@ -1,56 +1,61 @@ import { EmailMatch, HashtagMatch, MentionMatch, PhoneMatch } from 'autolinker/dist/es2015'; -export const getEmailUrl = (match: EmailMatch): string[] => [ - `mailto:${encodeURIComponent(match.getEmail())}`, -]; +export const getEmailUrl = (match: EmailMatch): string => + `mailto:${encodeURIComponent(match.getEmail())}`; export const getHashtagUrl = ( match: HashtagMatch, service: 'facebook' | 'instagram' | 'twitter' | false = false, -): string[] => { + native = false, +): string => { const tag = encodeURIComponent(match.getHashtag()); switch (service) { case 'facebook': - return [`fb://hashtag/${tag}`, `https://www.facebook.com/hashtag/${tag}`]; + return native ? `fb://hashtag/${tag}` : `https://www.facebook.com/hashtag/${tag}`; case 'instagram': - return [`instagram://tag?name=${tag}`, `https://www.instagram.com/explore/tags/${tag}/`]; + return native + ? `instagram://tag?name=${tag}` + : `https://www.instagram.com/explore/tags/${tag}/`; case 'twitter': - return [`twitter://search?query=%23${tag}`, `https://twitter.com/hashtag/${tag}`]; + return native ? `twitter://search?query=%23${tag}` : `https://twitter.com/hashtag/${tag}`; default: - return [match.getMatchedText()]; + return match.getMatchedText(); } }; export const getMentionUrl = ( match: MentionMatch, service: 'instagram' | 'soundcloud' | 'twitter' | false = false, -): string[] => { + native = false, +): string => { const username = match.getMention(); switch (service) { case 'instagram': - return [`instagram://user?username=${username}`, `https://www.instagram.com/${username}/`]; + return native + ? `instagram://user?username=${username}` + : `https://www.instagram.com/${username}/`; case 'soundcloud': - return [`https://soundcloud.com/${username}`]; + return `https://soundcloud.com/${username}`; case 'twitter': - return [`twitter://user?screen_name=${username}`, `https://twitter.com/${username}`]; + return native ? `twitter://user?screen_name=${username}` : `https://twitter.com/${username}`; default: - return [match.getMatchedText()]; + return match.getMatchedText(); } }; export const getPhoneUrl = ( match: PhoneMatch, method: 'sms' | 'tel' | 'text' | boolean = 'tel', -): string[] => { +): string => { const number = (match as PhoneMatch).getNumber(); switch (method) { case 'sms': case 'text': - return [`sms:${number}`]; + return `sms:${number}`; default: - return [`tel:${number}`]; + return `tel:${number}`; } };