Skip to content

Commit

Permalink
feat: default to web URLs for hashtag/mention links and remove webFal…
Browse files Browse the repository at this point in the history
…lback prop

Since `webFallback` has been disabled on iOS since the beginning and Android has potential upcoming
changes that could require disabling there too, `webFallback` has been removed and services are
instead linked to the web versions by default. The `useNativeSchemes` prop can be used to default
to native URL schemes instead. But the recommended approach is to use the `onPress` and/or
`matchers` props to customize behavior to your liking for hastag and mention links.

BREAKING CHANGE: The webFallback prop has been removed and service links for hashtags/mentions
default to web URLs. Use the `useNativeSchemes` to link directly to apps instead or use
`onPress`/`onLongPress`/`matchers` to customize behavior.
  • Loading branch information
joshswan committed Apr 4, 2021
1 parent 009db32 commit b8b4fa8
Show file tree
Hide file tree
Showing 5 changed files with 100 additions and 89 deletions.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,13 @@ type UrlConfig = {
<Autolink text={text} url={{ tldMatches: false }} />
```

### `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

Expand Down
36 changes: 11 additions & 25 deletions src/Autolink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -75,7 +66,7 @@ export interface AutolinkProps {
wwwMatches?: boolean;
tldMatches?: boolean;
};
webFallback?: boolean;
useNativeSchemes?: boolean;
}

type AutolinkComponentProps<C extends React.ElementType = typeof Text> = PolymorphicComponentProps<
Expand Down Expand Up @@ -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<C>): 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(
Expand All @@ -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(
Expand All @@ -170,7 +156,7 @@ export const Autolink = React.memo(
}

if (onLongPressProp) {
const [linkUrl] = getUrl(match);
const linkUrl = getUrl(match);
onLongPressProp(linkUrl, match);
}
},
Expand Down
12 changes: 11 additions & 1 deletion src/__tests__/Autolink.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ describe('<Autolink />', () => {
);
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', () => {
Expand All @@ -263,6 +263,16 @@ describe('<Autolink />', () => {
);
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(
<Autolink text="@twitter" mention="twitter" onPress={onPress} useNativeSchemes />,
);
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');
});

Expand Down
94 changes: 52 additions & 42 deletions src/__tests__/urls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
);
});
});
});
37 changes: 21 additions & 16 deletions src/urls.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
};

0 comments on commit b8b4fa8

Please sign in to comment.