Skip to content

Commit

Permalink
feat: user-specified custom link pattern handling (#54)
Browse files Browse the repository at this point in the history
* refactor: simplify Matchers expression

* feat: add user-specified custom link pattern handling

* fix: export UserCustomMatch types
  • Loading branch information
lafiosca authored Mar 28, 2021
1 parent 7b6c907 commit 3f91a09
Show file tree
Hide file tree
Showing 4 changed files with 168 additions and 4 deletions.
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class MyComponent extends Component {
## Props

- [`component?`](#component)
- [`customLinks?`](#customLinks)
- [`email?`](#email)
- [`hashtag?`](#hashtag)
- [`latlng?`](#latlng)
Expand Down Expand Up @@ -67,6 +68,51 @@ class MyComponent extends Component {
<Autolink text={text} component={View} />
```

### `customLinks`

| Type | Required | Default | Description |
| ----------------------- | -------- | ------- | ----------- |
| `UserCustomMatchSpec[]` | No | | Specifications for custom link patterns and their handling (see below). |

This property allows the user to establish custom link patterns and handling. It is particularly useful for mixing internal app navigation links with standard external links within the same block of content.

```ts
interface UserCustomMatchSpec {
/** Regular expression pattern to match user-specified custom links */
pattern: RegExp;
/** Custom function for extracting link text from regex replacer args */
extractText?: (replacerArgs: ReplacerArgs) => string;
/** Custom function for extracting link URL from regex replacer args */
extractUrl?: (replacerArgs: ReplacerArgs) => string;
/** Custom override for styling links of this type */
style?: StyleProp<TextStyle>;
/** Custom override for handling presses on links of this type */
onPress?: (replacerArgs: ReplacerArgs) => void;
/** Custom override for handling long-presses on links of this type */
onLongPress?: (replacerArgs: ReplacerArgs) => void;
}
```

The `ReplacerArgs` type is an array containing the variadic arguments passed to a replacer function as provided to `String.replace`. Essentially, element 0 is the entire matched link, and elements 1 through N are any captured subexpressions. More details can be found [in this documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_function_as_a_parameter).

When using the built-in link handling, the `extractUrl` function can be provided to determine the URL to which the link should navigate. Alternatively the `onPress` function will bypass that entirely, allowing the user to provide custom handling specific to this link type, useful for navigating within the application.

The following hypothetical example handles custom @-mention links of the format `@[Name](userId)`, navigating to a user profile screen:

```tsx
<Autolink
text={text}
customLinks={[{
pattern: /@\[([^[]*)]\(([^(^)]*)\)/g,
style: { color: '#ff00ff' },
extractText: (args) => `@${args[1]}`,
onPress: (args) => {
navigate('userProfile', { userId: args[2] });
},
}]}
/>
```

### `email`

| Type | Required | Default | Description |
Expand Down
44 changes: 41 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ import {
} from 'react-native';
import * as Truncate from './truncate';
import { Matchers, MatcherId, LatLngMatch } from './matchers';
import { UserCustomMatch, UserCustomMatchSpec } from './user-custom-match';
import { PropsOf } from './types';

export * from './user-custom-match';

const tagBuilder = new AnchorTagBuilder();

const styles = StyleSheet.create({
Expand All @@ -40,6 +43,7 @@ const styles = StyleSheet.create({

interface AutolinkProps<C extends React.ComponentType = React.ComponentType> {
component?: C;
customLinks?: UserCustomMatchSpec[];
email?: boolean;
hashtag?: false | 'facebook' | 'instagram' | 'twitter';
latlng?: boolean;
Expand Down Expand Up @@ -216,6 +220,7 @@ export default class Autolink<
return [`tel:${number}`];
}
}
case 'userCustom':
case 'url': {
return [match.getAnchorHref()];
}
Expand All @@ -234,11 +239,24 @@ export default class Autolink<
const { truncate, linkStyle } = this.props;
const truncated = truncate ? Autolink.truncate(text, this.props) : text;

let style: StyleProp<TextStyle> | undefined;
let onPress: (() => void) | undefined;
let onLongPress: (() => void) | undefined;
if (match.getType() === 'userCustom') {
style = (match as UserCustomMatch).getStyle();
onPress = (match as UserCustomMatch).getOnPress();
onLongPress = (match as UserCustomMatch).getOnLongPress();
}

style = style ?? linkStyle ?? styles.link;
onPress = onPress ?? (() => this.onPress(match));
onLongPress = onLongPress ?? (() => this.onLongPress(match));

return (
<Text
style={linkStyle || styles.link}
onPress={() => this.onPress(match)}
onLongPress={() => this.onLongPress(match)}
style={style}
onPress={onPress}
onLongPress={onLongPress}
// eslint-disable-next-line react/jsx-props-no-spreading
{...textProps}
key={index}
Expand All @@ -252,6 +270,7 @@ export default class Autolink<
const {
children,
component = Text,
customLinks = [],
email,
hashtag,
latlng,
Expand Down Expand Up @@ -326,6 +345,24 @@ export default class Autolink<
});
}
});

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

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

return token;
});
});
} catch (e) {
console.warn(e); // eslint-disable-line no-console

Expand All @@ -345,6 +382,7 @@ export default class Autolink<
case 'mention':
case 'phone':
case 'url':
case 'userCustom':
return renderLink
? renderLink(match.getAnchorText(), match, index)
: this.renderLink(match.getAnchorText(), match, index, linkProps);
Expand Down
2 changes: 1 addition & 1 deletion src/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ export const CustomMatchers = {

export type MatcherId = keyof typeof CustomMatchers;

export const Matchers = Object.keys(CustomMatchers).map((key) => CustomMatchers[key as MatcherId]);
export const Matchers = Object.values(CustomMatchers);
80 changes: 80 additions & 0 deletions src/user-custom-match.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Match, MatchConfig } from 'autolinker/dist/es2015';
import { StyleProp, TextStyle } from 'react-native';

// The variadic arguments of a regex replacer function, wrapped in an array.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ReplacerArgs = [string, ...any[]];

export interface UserCustomMatchSpec {
/** Regular expression pattern to match user-specified custom links */
pattern: RegExp;
/** Custom function for extracting link text from regex replacer args */
extractText?: (replacerArgs: ReplacerArgs) => string;
/** Custom function for extracting link URL from regex replacer args */
extractUrl?: (replacerArgs: ReplacerArgs) => string;
/** Custom override for styling links of this type */
style?: StyleProp<TextStyle>;
/** Custom override for handling presses on links of this type */
onPress?: (replacerArgs: ReplacerArgs) => void;
/** Custom override for handling long-presses on links of this type */
onLongPress?: (replacerArgs: ReplacerArgs) => void;
}

export interface UserCustomMatchConfig extends MatchConfig, UserCustomMatchSpec {
replacerArgs: ReplacerArgs;
}

export class UserCustomMatch extends Match {
private replacerArgs: ReplacerArgs;

private extractUrl?: (replacerArgs: ReplacerArgs) => string;

private extractText?: (replacerArgs: ReplacerArgs) => string;

private style?: StyleProp<TextStyle>;

private onPress?: () => void;

private onLongPress?: () => void;

constructor(cfg: UserCustomMatchConfig) {
super(cfg);

this.replacerArgs = cfg.replacerArgs;
this.extractUrl = cfg.extractUrl;
this.extractText = cfg.extractText;
this.style = cfg.style;

const { onPress, onLongPress } = cfg;
if (onPress) {
this.onPress = () => onPress(this.replacerArgs);
}
if (onLongPress) {
this.onLongPress = () => onLongPress(this.replacerArgs);
}
}

getType(): string {
return 'userCustom';
}

getAnchorHref(): string {
return this.extractUrl?.(this.replacerArgs) ?? this.matchedText;
}

getAnchorText(): string {
return this.extractText?.(this.replacerArgs) ?? this.matchedText;
}

getStyle(): StyleProp<TextStyle> | undefined {
return this.style;
}

getOnPress(): (() => void) | undefined {
return this.onPress;
}

getOnLongPress(): (() => void) | undefined {
return this.onLongPress;
}
}

0 comments on commit 3f91a09

Please sign in to comment.