Skip to content

Commit

Permalink
refactor: convert Autolink to functional component with hooks and exp…
Browse files Browse the repository at this point in the history
…ort 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 #49
  • Loading branch information
joshswan committed Mar 29, 2021
1 parent d229141 commit c94ac87
Show file tree
Hide file tree
Showing 8 changed files with 410 additions and 381 deletions.
276 changes: 276 additions & 0 deletions src/Autolink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
/*!
* React Native Autolink
*
* Copyright 2016-2021 Josh Swan
* Released under the MIT license
* https://github.com/joshswan/react-native-autolink/blob/master/LICENSE
*/

import React, { createElement, useCallback, useRef } from 'react';
import {
Autolinker,
AnchorTagBuilder,
Match,
EmailMatch,
HashtagMatch,
MentionMatch,
PhoneMatch,
} from 'autolinker/dist/es2015';
import {
Alert,
Linking,
Platform,
StyleSheet,
StyleProp,
Text,
TextStyle,
TextProps,
} from 'react-native';
import { truncate } from './truncate';
import { CustomMatch, CustomMatcher } from './CustomMatch';
import { PolymorphicComponentProps } from './types';
import * as urls from './urls';

const makeTokenGenerator = (uid: string): [() => string, RegExp] => {
let counter = 0;
return [
// eslint-disable-next-line no-plusplus
() => `@__ELEMENT-${uid}-${counter++}__@`,
new RegExp(`(@__ELEMENT-${uid}-\\d+__@)`, 'g'),
];
};

const styles = StyleSheet.create({
link: {
color: '#0E7AFE',
},
});

const tagBuilder = new AnchorTagBuilder();

export interface AutolinkProps {
email?: boolean;
hashtag?: false | 'facebook' | 'instagram' | 'twitter';
linkProps?: TextProps;
linkStyle?: StyleProp<TextStyle>;
matchers?: CustomMatcher[];
mention?: false | 'instagram' | 'soundcloud' | 'twitter';
onPress?: (url: string, match: Match) => void;
onLongPress?: (url: string, match: Match) => void;
phone?: boolean | 'text' | 'sms';
renderLink?: (text: string, match: Match, index: number) => React.ReactNode;
renderText?: (text: string, index: number) => React.ReactNode;
showAlert?: boolean;
stripPrefix?: boolean;
stripTrailingSlash?: boolean;
text: string;
textProps?: TextProps;
truncate?: number;
truncateChars?: string;
truncateLocation?: 'end' | 'middle' | 'smart';
url?:
| boolean
| {
schemeMatches?: boolean;
wwwMatches?: boolean;
tldMatches?: boolean;
};
webFallback?: boolean;
}

type AutolinkComponentProps<C extends React.ElementType = typeof Text> = PolymorphicComponentProps<
C,
AutolinkProps
>;

export const Autolink = React.memo(
<C extends React.ElementType = typeof Text>({
as,
component,
email = true,
hashtag = false,
linkProps = {},
linkStyle,
matchers = [],
mention = false,
onPress: onPressProp,
onLongPress: onLongPressProp,
phone = false,
renderLink: renderLinkProp,
renderText,
showAlert = false,
stripPrefix = true,
stripTrailingSlash = true,
text,
textProps = {},
truncate: truncateProp = 0,
truncateChars = '..',
truncateLocation = 'smart',
url = true,
// iOS requires LSApplicationQueriesSchemes for Linking.canOpenURL
webFallback = Platform.OS !== 'ios' && Platform.OS !== 'macos',
...props
}: AutolinkComponentProps<C>): JSX.Element | null => {
const getUrl = useCallback(
(match: Match): string[] => {
switch (match.getType()) {
case 'email':
return urls.getEmailUrl(match as EmailMatch);
case 'hashtag':
return urls.getHashtagUrl(match as HashtagMatch, hashtag);
case 'mention':
return urls.getMentionUrl(match as MentionMatch, mention);
case 'phone':
return urls.getPhoneUrl(match as PhoneMatch, phone);
default:
return [match.getAnchorHref()];
}
},
[hashtag, mention, phone],
);

const onPress = useCallback(
(match: Match, alertShown?: boolean): void => {
// Bypass default press handling if matcher has custom onPress
if (match instanceof CustomMatch && match.getMatcher().onPress) {
match.getMatcher().onPress?.(match);
return;
}

// Check if alert needs to be shown
if (showAlert && !alertShown) {
Alert.alert('Leaving App', 'Do you want to continue?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'OK', onPress: () => onPress(match, true) },
]);
return;
}

const [linkUrl, fallbackUrl] = 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],
);

const onLongPress = useCallback(
(match: Match): void => {
// Bypass default press handling if matcher has custom onLongPress
if (match instanceof CustomMatch && match.getMatcher().onLongPress) {
match.getMatcher().onLongPress?.(match);
return;
}

if (onLongPressProp) {
const [linkUrl] = getUrl(match);
onLongPressProp(linkUrl, match);
}
},
[getUrl, onLongPressProp],
);

const renderLink = useCallback(
(linkText: string, match: Match | CustomMatch, index: number) => {
const truncated = truncateProp
? truncate(linkText, truncateProp, truncateChars, truncateLocation)
: linkText;

return (
<Text
style={
(match instanceof CustomMatch && match.getMatcher().style) || linkStyle || styles.link
}
onPress={() => onPress(match)}
onLongPress={() => onLongPress(match)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...linkProps}
key={index}
>
{truncated}
</Text>
);
},
[linkProps, linkStyle, truncateProp, truncateChars, truncateLocation, onPress, onLongPress],
);

// Creates a token with a random UID that should not be guessable or
// conflict with other parts of the string.
const uid = useRef(Math.floor(Math.random() * 0x10000000000).toString(16));
const [generateToken, tokenRegexp] = makeTokenGenerator(uid.current);

const matches: { [token: string]: Match } = {};
let linkedText: string;

try {
linkedText = Autolinker.link(text || '', {
email,
hashtag,
mention,
phone: !!phone,
urls: url,
stripPrefix,
stripTrailingSlash,
replaceFn: (match) => {
const token = generateToken();

matches[token] = match;

return token;
},
});

// User-specified custom matchers
matchers.forEach((matcher) => {
linkedText = linkedText.replace(matcher.pattern, (...replacerArgs) => {
const token = generateToken();
const matchedText = replacerArgs[0];

matches[token] = new CustomMatch({
matcher,
matchedText,
offset: replacerArgs[replacerArgs.length - 2],
replacerArgs,
tagBuilder,
});

return token;
});
});
} catch (e) {
// eslint-disable-next-line no-console
console.warn('RN Autolink error:', e);
return null;
}

const nodes = linkedText
.split(tokenRegexp)
.filter((part) => !!part)
.map((part, index) => {
const match = matches[part];

// Check if rendering link or text node
if (match?.getType()) {
return (renderLinkProp || renderLink)(match.getAnchorText(), match, index);
}

return renderText ? (
renderText(part, index)
) : (
// eslint-disable-next-line react/jsx-props-no-spreading, react/no-array-index-key
<Text {...textProps} key={index}>
{part}
</Text>
);
});

return createElement(as || component || Text, props, ...nodes);
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import React from 'react';
import { Text, View } from 'react-native';
import renderer from 'react-test-renderer';
import Autolink from '..';
import { Autolink } from '../Autolink';
import { LatLngMatcher } from '../matchers';

describe('<Autolink />', () => {
Expand Down Expand Up @@ -157,13 +157,6 @@ describe('<Autolink />', () => {
expect(tree).toMatchSnapshot();
});

test('does not truncate urls when zero is passed for truncate prop', () => {
const tree = renderer
.create(<Autolink text="github.com/joshswan/react-native-autolink" truncate={0} />)
.toJSON();
expect(tree).toMatchSnapshot();
});

test('replaces removed protion of truncated url with truncateChars prop value', () => {
const tree = renderer
.create(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,22 +56,6 @@ exports[`<Autolink /> does not remove url trailing slashes when stripTrailingSla
</Text>
`;

exports[`<Autolink /> does not truncate urls when zero is passed for truncate prop 1`] = `
<Text>
<Text
onLongPress={[Function]}
onPress={[Function]}
style={
Object {
"color": "#0E7AFE",
}
}
>
github.com/joshswan/react-native-autolink
</Text>
</Text>
`;

exports[`<Autolink /> does not wrap a hashtag in a link Text node when hashtag prop disabled 1`] = `
<Text>
<Text>
Expand Down Expand Up @@ -168,7 +152,7 @@ exports[`<Autolink /> links multiple elements individually 1`] = `
}
}
>
github.com/joshswan/..e-autolink
github.com/joshswan/react-native-autolink
</Text>
<Text>
. It's
Expand Down Expand Up @@ -413,7 +397,7 @@ exports[`<Autolink /> wraps a url in a link Text node when url prop enabled 1`]
}
}
>
github.com/joshswan/..e-autolink
github.com/joshswan/react-native-autolink
</Text>
</Text>
`;
Expand Down
17 changes: 17 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*!
* React Native Autolink
*
* Copyright 2016-2021 Josh Swan
* Released under the MIT license
* https://github.com/joshswan/react-native-autolink/blob/master/LICENSE
*/

import { Autolink } from './Autolink';

export * from './Autolink';
export * from './CustomMatch';
export * from './matchers';
export { truncate } from './truncate';
export * from './urls';

export default Autolink;

0 comments on commit c94ac87

Please sign in to comment.