Skip to content

Commit c94ac87

Browse files
committed
refactor: convert Autolink to functional component with hooks and export utils
Replace Autolink class component with functional component that uses hooks. Useful utils such as url-getters and truncate are now exported. BREAKING CHANGE: Link types are all disabled by default - pass `email`, `hashtag`, etc. props to enable. Truncation is also disabled by default - use `truncate={32}` to enable previous behavior closes joshswan#49
1 parent d229141 commit c94ac87

File tree

8 files changed

+410
-381
lines changed

8 files changed

+410
-381
lines changed

src/Autolink.tsx

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/*!
2+
* React Native Autolink
3+
*
4+
* Copyright 2016-2021 Josh Swan
5+
* Released under the MIT license
6+
* https://github.com/joshswan/react-native-autolink/blob/master/LICENSE
7+
*/
8+
9+
import React, { createElement, useCallback, useRef } from 'react';
10+
import {
11+
Autolinker,
12+
AnchorTagBuilder,
13+
Match,
14+
EmailMatch,
15+
HashtagMatch,
16+
MentionMatch,
17+
PhoneMatch,
18+
} from 'autolinker/dist/es2015';
19+
import {
20+
Alert,
21+
Linking,
22+
Platform,
23+
StyleSheet,
24+
StyleProp,
25+
Text,
26+
TextStyle,
27+
TextProps,
28+
} from 'react-native';
29+
import { truncate } from './truncate';
30+
import { CustomMatch, CustomMatcher } from './CustomMatch';
31+
import { PolymorphicComponentProps } from './types';
32+
import * as urls from './urls';
33+
34+
const makeTokenGenerator = (uid: string): [() => string, RegExp] => {
35+
let counter = 0;
36+
return [
37+
// eslint-disable-next-line no-plusplus
38+
() => `@__ELEMENT-${uid}-${counter++}__@`,
39+
new RegExp(`(@__ELEMENT-${uid}-\\d+__@)`, 'g'),
40+
];
41+
};
42+
43+
const styles = StyleSheet.create({
44+
link: {
45+
color: '#0E7AFE',
46+
},
47+
});
48+
49+
const tagBuilder = new AnchorTagBuilder();
50+
51+
export interface AutolinkProps {
52+
email?: boolean;
53+
hashtag?: false | 'facebook' | 'instagram' | 'twitter';
54+
linkProps?: TextProps;
55+
linkStyle?: StyleProp<TextStyle>;
56+
matchers?: CustomMatcher[];
57+
mention?: false | 'instagram' | 'soundcloud' | 'twitter';
58+
onPress?: (url: string, match: Match) => void;
59+
onLongPress?: (url: string, match: Match) => void;
60+
phone?: boolean | 'text' | 'sms';
61+
renderLink?: (text: string, match: Match, index: number) => React.ReactNode;
62+
renderText?: (text: string, index: number) => React.ReactNode;
63+
showAlert?: boolean;
64+
stripPrefix?: boolean;
65+
stripTrailingSlash?: boolean;
66+
text: string;
67+
textProps?: TextProps;
68+
truncate?: number;
69+
truncateChars?: string;
70+
truncateLocation?: 'end' | 'middle' | 'smart';
71+
url?:
72+
| boolean
73+
| {
74+
schemeMatches?: boolean;
75+
wwwMatches?: boolean;
76+
tldMatches?: boolean;
77+
};
78+
webFallback?: boolean;
79+
}
80+
81+
type AutolinkComponentProps<C extends React.ElementType = typeof Text> = PolymorphicComponentProps<
82+
C,
83+
AutolinkProps
84+
>;
85+
86+
export const Autolink = React.memo(
87+
<C extends React.ElementType = typeof Text>({
88+
as,
89+
component,
90+
email = true,
91+
hashtag = false,
92+
linkProps = {},
93+
linkStyle,
94+
matchers = [],
95+
mention = false,
96+
onPress: onPressProp,
97+
onLongPress: onLongPressProp,
98+
phone = false,
99+
renderLink: renderLinkProp,
100+
renderText,
101+
showAlert = false,
102+
stripPrefix = true,
103+
stripTrailingSlash = true,
104+
text,
105+
textProps = {},
106+
truncate: truncateProp = 0,
107+
truncateChars = '..',
108+
truncateLocation = 'smart',
109+
url = true,
110+
// iOS requires LSApplicationQueriesSchemes for Linking.canOpenURL
111+
webFallback = Platform.OS !== 'ios' && Platform.OS !== 'macos',
112+
...props
113+
}: AutolinkComponentProps<C>): JSX.Element | null => {
114+
const getUrl = useCallback(
115+
(match: Match): string[] => {
116+
switch (match.getType()) {
117+
case 'email':
118+
return urls.getEmailUrl(match as EmailMatch);
119+
case 'hashtag':
120+
return urls.getHashtagUrl(match as HashtagMatch, hashtag);
121+
case 'mention':
122+
return urls.getMentionUrl(match as MentionMatch, mention);
123+
case 'phone':
124+
return urls.getPhoneUrl(match as PhoneMatch, phone);
125+
default:
126+
return [match.getAnchorHref()];
127+
}
128+
},
129+
[hashtag, mention, phone],
130+
);
131+
132+
const onPress = useCallback(
133+
(match: Match, alertShown?: boolean): void => {
134+
// Bypass default press handling if matcher has custom onPress
135+
if (match instanceof CustomMatch && match.getMatcher().onPress) {
136+
match.getMatcher().onPress?.(match);
137+
return;
138+
}
139+
140+
// Check if alert needs to be shown
141+
if (showAlert && !alertShown) {
142+
Alert.alert('Leaving App', 'Do you want to continue?', [
143+
{ text: 'Cancel', style: 'cancel' },
144+
{ text: 'OK', onPress: () => onPress(match, true) },
145+
]);
146+
return;
147+
}
148+
149+
const [linkUrl, fallbackUrl] = getUrl(match);
150+
151+
if (onPressProp) {
152+
onPressProp(linkUrl, match);
153+
} else if (webFallback) {
154+
Linking.canOpenURL(linkUrl).then((supported) => {
155+
Linking.openURL(!supported && fallbackUrl ? fallbackUrl : linkUrl);
156+
});
157+
} else {
158+
Linking.openURL(linkUrl);
159+
}
160+
},
161+
[getUrl, onPressProp, showAlert, webFallback],
162+
);
163+
164+
const onLongPress = useCallback(
165+
(match: Match): void => {
166+
// Bypass default press handling if matcher has custom onLongPress
167+
if (match instanceof CustomMatch && match.getMatcher().onLongPress) {
168+
match.getMatcher().onLongPress?.(match);
169+
return;
170+
}
171+
172+
if (onLongPressProp) {
173+
const [linkUrl] = getUrl(match);
174+
onLongPressProp(linkUrl, match);
175+
}
176+
},
177+
[getUrl, onLongPressProp],
178+
);
179+
180+
const renderLink = useCallback(
181+
(linkText: string, match: Match | CustomMatch, index: number) => {
182+
const truncated = truncateProp
183+
? truncate(linkText, truncateProp, truncateChars, truncateLocation)
184+
: linkText;
185+
186+
return (
187+
<Text
188+
style={
189+
(match instanceof CustomMatch && match.getMatcher().style) || linkStyle || styles.link
190+
}
191+
onPress={() => onPress(match)}
192+
onLongPress={() => onLongPress(match)}
193+
// eslint-disable-next-line react/jsx-props-no-spreading
194+
{...linkProps}
195+
key={index}
196+
>
197+
{truncated}
198+
</Text>
199+
);
200+
},
201+
[linkProps, linkStyle, truncateProp, truncateChars, truncateLocation, onPress, onLongPress],
202+
);
203+
204+
// Creates a token with a random UID that should not be guessable or
205+
// conflict with other parts of the string.
206+
const uid = useRef(Math.floor(Math.random() * 0x10000000000).toString(16));
207+
const [generateToken, tokenRegexp] = makeTokenGenerator(uid.current);
208+
209+
const matches: { [token: string]: Match } = {};
210+
let linkedText: string;
211+
212+
try {
213+
linkedText = Autolinker.link(text || '', {
214+
email,
215+
hashtag,
216+
mention,
217+
phone: !!phone,
218+
urls: url,
219+
stripPrefix,
220+
stripTrailingSlash,
221+
replaceFn: (match) => {
222+
const token = generateToken();
223+
224+
matches[token] = match;
225+
226+
return token;
227+
},
228+
});
229+
230+
// User-specified custom matchers
231+
matchers.forEach((matcher) => {
232+
linkedText = linkedText.replace(matcher.pattern, (...replacerArgs) => {
233+
const token = generateToken();
234+
const matchedText = replacerArgs[0];
235+
236+
matches[token] = new CustomMatch({
237+
matcher,
238+
matchedText,
239+
offset: replacerArgs[replacerArgs.length - 2],
240+
replacerArgs,
241+
tagBuilder,
242+
});
243+
244+
return token;
245+
});
246+
});
247+
} catch (e) {
248+
// eslint-disable-next-line no-console
249+
console.warn('RN Autolink error:', e);
250+
return null;
251+
}
252+
253+
const nodes = linkedText
254+
.split(tokenRegexp)
255+
.filter((part) => !!part)
256+
.map((part, index) => {
257+
const match = matches[part];
258+
259+
// Check if rendering link or text node
260+
if (match?.getType()) {
261+
return (renderLinkProp || renderLink)(match.getAnchorText(), match, index);
262+
}
263+
264+
return renderText ? (
265+
renderText(part, index)
266+
) : (
267+
// eslint-disable-next-line react/jsx-props-no-spreading, react/no-array-index-key
268+
<Text {...textProps} key={index}>
269+
{part}
270+
</Text>
271+
);
272+
});
273+
274+
return createElement(as || component || Text, props, ...nodes);
275+
},
276+
);

src/__tests__/index.test.tsx renamed to src/__tests__/Autolink.test.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import React from 'react';
1010
import { Text, View } from 'react-native';
1111
import renderer from 'react-test-renderer';
12-
import Autolink from '..';
12+
import { Autolink } from '../Autolink';
1313
import { LatLngMatcher } from '../matchers';
1414

1515
describe('<Autolink />', () => {
@@ -157,13 +157,6 @@ describe('<Autolink />', () => {
157157
expect(tree).toMatchSnapshot();
158158
});
159159

160-
test('does not truncate urls when zero is passed for truncate prop', () => {
161-
const tree = renderer
162-
.create(<Autolink text="github.com/joshswan/react-native-autolink" truncate={0} />)
163-
.toJSON();
164-
expect(tree).toMatchSnapshot();
165-
});
166-
167160
test('replaces removed protion of truncated url with truncateChars prop value', () => {
168161
const tree = renderer
169162
.create(

src/__tests__/__snapshots__/index.test.tsx.snap renamed to src/__tests__/__snapshots__/Autolink.test.tsx.snap

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,22 +56,6 @@ exports[`<Autolink /> does not remove url trailing slashes when stripTrailingSla
5656
</Text>
5757
`;
5858

59-
exports[`<Autolink /> does not truncate urls when zero is passed for truncate prop 1`] = `
60-
<Text>
61-
<Text
62-
onLongPress={[Function]}
63-
onPress={[Function]}
64-
style={
65-
Object {
66-
"color": "#0E7AFE",
67-
}
68-
}
69-
>
70-
github.com/joshswan/react-native-autolink
71-
</Text>
72-
</Text>
73-
`;
74-
7559
exports[`<Autolink /> does not wrap a hashtag in a link Text node when hashtag prop disabled 1`] = `
7660
<Text>
7761
<Text>
@@ -168,7 +152,7 @@ exports[`<Autolink /> links multiple elements individually 1`] = `
168152
}
169153
}
170154
>
171-
github.com/joshswan/..e-autolink
155+
github.com/joshswan/react-native-autolink
172156
</Text>
173157
<Text>
174158
. It's
@@ -413,7 +397,7 @@ exports[`<Autolink /> wraps a url in a link Text node when url prop enabled 1`]
413397
}
414398
}
415399
>
416-
github.com/joshswan/..e-autolink
400+
github.com/joshswan/react-native-autolink
417401
</Text>
418402
</Text>
419403
`;

src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*!
2+
* React Native Autolink
3+
*
4+
* Copyright 2016-2021 Josh Swan
5+
* Released under the MIT license
6+
* https://github.com/joshswan/react-native-autolink/blob/master/LICENSE
7+
*/
8+
9+
import { Autolink } from './Autolink';
10+
11+
export * from './Autolink';
12+
export * from './CustomMatch';
13+
export * from './matchers';
14+
export { truncate } from './truncate';
15+
export * from './urls';
16+
17+
export default Autolink;

0 commit comments

Comments
 (0)