Refactor link/emoji/newline components for composability

This commit is contained in:
Scott Nonnenberg 2018-05-18 14:48:20 -07:00
parent a5416e42c4
commit d9e5338dff
11 changed files with 295 additions and 95 deletions

View file

@ -0,0 +1,35 @@
### All newlines
```jsx
<AddNewLines text="\n\n\n" />
```
### Starting and ending with newlines
```jsx
<AddNewLines text="\nin between\n" />
```
### With newlines in the middle
```jsx
<AddNewLines text="Before \n\n after" />
```
### No newlines
```jsx
<AddNewLines text="This is the text" />
```
### Providing custom non-newline render function
```jsx
const renderNonNewLine = ({ text, key }) => (
<span key={key}>This is my custom content!</span>
);
<AddNewLines
text="\n first \n second \n"
renderNonNewLine={renderNonNewLine}
/>;
```

View file

@ -1,27 +1,43 @@
import React from 'react';
import { RenderTextCallback } from '../../types/Util';
interface Props {
text: string;
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
renderNonNewLine?: RenderTextCallback;
}
export class AddNewLines extends React.Component<Props, {}> {
public static defaultProps: Partial<Props> = {
renderNonNewLine: ({ text, key }) => <span key={key}>{text}</span>,
};
public render() {
const { text } = this.props;
const { text, renderNonNewLine } = this.props;
const results: Array<any> = [];
const FIND_NEWLINES = /\n/g;
// We have to do this, because renderNonNewLine is not required in our Props object,
// but it is always provided via defaultProps.
if (!renderNonNewLine) {
return;
}
let match = FIND_NEWLINES.exec(text);
let last = 0;
let count = 1;
if (!match) {
return <span>{text}</span>;
return renderNonNewLine({ text, key: 0 });
}
while (match) {
if (last < match.index) {
const textWithNoNewline = text.slice(last, match.index);
results.push(<span key={count++}>{textWithNoNewline}</span>);
results.push(
renderNonNewLine({ text: textWithNoNewline, key: count++ })
);
}
results.push(<br key={count++} />);
@ -32,9 +48,9 @@ export class AddNewLines extends React.Component<Props, {}> {
}
if (last < text.length) {
results.push(<span key={count++}>{text.slice(last)}</span>);
results.push(renderNonNewLine({ text: text.slice(last), key: count++ }));
}
return <span>{results}</span>;
return results;
}
}

View file

@ -17,7 +17,7 @@ import {
renderSendMessage,
} from './EmbeddedContact';
type Localizer = (key: string, values?: Array<string>) => string;
import { Localizer } from '../../types/Util';
interface Props {
contact: Contact;

View file

@ -0,0 +1,60 @@
### All emoji
```jsx
<Emojify text="🔥🔥🔥" />
```
### With skin color modifier
```jsx
<Emojify text="👍🏾" />
```
### With `sizeClass` provided
```jsx
<Emojify text="🔥" sizeClass="jumbo" />
```
```jsx
<Emojify text="🔥" sizeClass="large" />
```
```jsx
<Emojify text="🔥" sizeClass="medium" />
```
```jsx
<Emojify text="🔥" sizeClass="small" />
```
```jsx
<Emojify text="🔥" sizeClass="" />
```
### Starting and ending with emoji
```jsx
<Emojify text="🔥in between🔥" />
```
### With emoji in the middle
```jsx
<Emojify text="Before 🔥🔥 after" />
```
### No emoji
```jsx
<Emojify text="This is the text" />
```
### Providing custom non-link render function
```jsx
const renderNonEmoji = ({ text, key }) => (
<span key={key}>This is my custom content</span>
);
<Emojify text="Before 🔥🔥 after" renderNonEmoji={renderNonEmoji} />;
```

View file

@ -9,7 +9,8 @@ import {
getReplacementData,
getTitle,
} from '../../util/emoji';
import { AddNewLines } from './AddNewLines';
import { RenderTextCallback } from '../../types/Util';
// Some of this logic taken from emoji-js/replacement
function getImageTag({
@ -43,27 +44,40 @@ function getImageTag({
interface Props {
text: string;
sizeClass?: string;
/** A class name to be added to the generated emoji images */
sizeClass?: '' | 'small' | 'medium' | 'large' | 'jumbo';
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
renderNonEmoji?: RenderTextCallback;
}
export class Emojify extends React.Component<Props, {}> {
public static defaultProps: Partial<Props> = {
renderNonEmoji: ({ text, key }) => <span key={key}>{text}</span>,
};
public render() {
const { text, sizeClass } = this.props;
const { text, sizeClass, renderNonEmoji } = this.props;
const results: Array<any> = [];
const regex = getRegex();
// We have to do this, because renderNonEmoji is not required in our Props object,
// but it is always provided via defaultProps.
if (!renderNonEmoji) {
return;
}
let match = regex.exec(text);
let last = 0;
let count = 1;
if (!match) {
return <AddNewLines text={text} />;
return renderNonEmoji({ text, key: 0 });
}
while (match) {
if (last < match.index) {
const textWithNoEmoji = text.slice(last, match.index);
results.push(<AddNewLines key={count++} text={textWithNoEmoji} />);
results.push(renderNonEmoji({ text: textWithNoEmoji, key: count++ }));
}
results.push(getImageTag({ match, sizeClass, key: count++ }));
@ -73,9 +87,9 @@ export class Emojify extends React.Component<Props, {}> {
}
if (last < text.length) {
results.push(<AddNewLines key={count++} text={text.slice(last)} />);
results.push(renderNonEmoji({ text: text.slice(last), key: count++ }));
}
return <span>{results}</span>;
return results;
}
}

View file

@ -0,0 +1,44 @@
### All link
```jsx
<Linkify text="https://somewhere.com" />
```
### Starting and ending with link
```jsx
<Linkify text="https://somewhere.com Yes? No? https://anotherlink.com" />
```
### With a link in the middle
```jsx
<Linkify text="Before. https://somewhere.com After." />
```
### No link
```jsx
<Linkify text="Plain text" />
```
### Should not render as link
```jsx
<Linkify text="smailto:someone@somewhere.com - ftp://something.com - //local/share - \\local\share" />
```
### Should render as link
```jsx
<Linkify text="github.com - https://blah.com" />
```
### Providing custom non-link render function
```jsx
const renderNonLink = ({ text, key }) => (
<span key={key}>This is my custom non-link content!</span>
);
<Linkify text="Before github.com After" renderNonLink={renderNonLink} />;
```

View file

@ -0,0 +1,72 @@
import React from 'react';
import createLinkify from 'linkify-it';
import { RenderTextCallback } from '../../types/Util';
const linkify = createLinkify();
interface Props {
text: string;
/** Allows you to customize now non-links are rendered. Simplest is just a <span>. */
renderNonLink?: RenderTextCallback;
}
const SUPPORTED_PROTOCOLS = /^(http|https):/i;
export class Linkify extends React.Component<Props, {}> {
public static defaultProps: Partial<Props> = {
renderNonLink: ({ text, key }) => <span key={key}>{text}</span>,
};
public render() {
const { text, renderNonLink } = this.props;
const matchData = linkify.match(text) || [];
const results: Array<any> = [];
let last = 0;
let count = 1;
// We have to do this, because renderNonLink is not required in our Props object,
// but it is always provided via defaultProps.
if (!renderNonLink) {
return;
}
if (matchData.length === 0) {
return renderNonLink({ text, key: 0 });
}
matchData.forEach(
(match: {
index: number;
url: string;
lastIndex: number;
text: string;
}) => {
if (last < match.index) {
const textWithNoLink = text.slice(last, match.index);
results.push(renderNonLink({ text: textWithNoLink, key: count++ }));
}
const { url, text: originalText } = match;
if (SUPPORTED_PROTOCOLS.test(url)) {
results.push(
<a key={count++} href={url}>
{originalText}
</a>
);
} else {
results.push(renderNonLink({ text: originalText, key: count++ }));
}
last = match.lastIndex;
}
);
if (last < text.length) {
results.push(renderNonLink({ text: text.slice(last), key: count++ }));
}
return results;
}
}

View file

@ -1,11 +1,7 @@
### Plain text
### All components: emoji, links, newline
```jsx
<MessageBody text="Plain text message" />
```
```jsx
<MessageBody text="Plain text message\n\nWith a new line." />
<MessageBody text="Fire 🔥 http://somewhere.com\nSecond Line" />
```
### Jumbo emoji
@ -31,33 +27,17 @@
```
```jsx
<MessageBody text="With skin color modifier: 👍🏾" />
<MessageBody text="🔥 text disables jumbomoji" />
```
### Text and emoji
### Jumbomoji disabled
```jsx
<MessageBody text="Plain text 🔥message. With 🔥emoji🔥 sprinkled 🔥about" />
<MessageBody text="🔥" disableJumbomoji />
```
### Links disabled
```jsx
<MessageBody text="🔥Message starting and ending with emoji🔥" />
```
### Links
```jsx
<MessageBody text="This before and after link. Before. https://somewhere.com After." />
```
```jsx
<MessageBody text="Link https://somewhere.com\nWhat do you think? How about this one? \n\nhttps://anotherlink.com" />
```
```jsx
<MessageBody text="Link https://somewhere.com\nWhat do you think? How about this one? \n\nhttps://anotherlink.com" />
```
```jsx
<MessageBody text="should not render as link:\nmailto:someone@somewhere.com\nftp://something.com\n//local/share\n\\local\share\n\nshould render as link:\ngithub.com\nhttps://blah.com" />
<MessageBody text="http://somewhere.com" disableLinks />
```

View file

@ -1,67 +1,46 @@
import React from 'react';
import createLinkify from 'linkify-it';
import { getSizeClass } from '../../util/emoji';
import { Emojify } from './Emojify';
import { AddNewLines } from './AddNewLines';
import { Linkify } from './Linkify';
const linkify = createLinkify();
import { RenderTextCallback } from '../../types/Util';
interface Props {
text: string;
/** If set, all emoji will be the same size. Otherwise, just one emoji will be large. */
disableJumbomoji?: boolean;
/** If set, links will be left alone instead of turned into clickable `<a>` tags. */
disableLinks?: boolean;
}
const SUPPORTED_PROTOCOLS = /^(http|https):/i;
const renderNewLines: RenderTextCallback = ({
text: textWithNewLines,
key,
}) => <AddNewLines key={key} text={textWithNewLines} />;
const renderLinks: RenderTextCallback = ({ text: textWithLinks, key }) => (
<Linkify key={key} text={textWithLinks} renderNonLink={renderNewLines} />
);
/**
* This component makes it very easy to use all three of our message formatting
* components: `Emojify`, `Linkify`, and `AddNewLines`. Because each of them is fully
* configurable with their `renderXXX` props, this component will assemble all three of
* them for you.
*/
export class MessageBody extends React.Component<Props, {}> {
public render() {
const { text, disableJumbomoji, disableLinks } = this.props;
const matchData = linkify.match(text) || [];
const results: Array<any> = [];
let last = 0;
let count = 1;
// We only use this sizeClass if there was no link detected, because jumbo emoji
// only fire when there's no other text in the message.
const sizeClass = disableJumbomoji ? '' : getSizeClass(text);
if (disableLinks || matchData.length === 0) {
return <Emojify text={text} sizeClass={sizeClass} />;
}
matchData.forEach(
(match: {
index: number;
url: string;
lastIndex: number;
text: string;
}) => {
if (last < match.index) {
const textWithNoLink = text.slice(last, match.index);
results.push(<Emojify key={count++} text={textWithNoLink} />);
}
const { url, text: originalText } = match;
if (SUPPORTED_PROTOCOLS.test(url)) {
results.push(
<a key={count++} href={url}>
{originalText}
</a>
);
} else {
results.push(<Emojify key={count++} text={originalText} />);
}
last = match.lastIndex;
}
return (
<Emojify
text={text}
sizeClass={sizeClass}
renderNonEmoji={disableLinks ? renderNewLines : renderLinks}
/>
);
if (last < text.length) {
results.push(<Emojify key={count++} text={text.slice(last)} />);
}
return <span>{results}</span>;
}
}

View file

@ -218,11 +218,3 @@ parent.storage.put('regionCode', 'US');
// Telling Lodash to relinquish _ for use by underscore
// @ts-ignore
_.noConflict();
parent.emoji.signalReplace = (html: string): string => {
return html.replace(
/🔥/g,
'<img src="node_modules/emoji-datasource-apple/img/apple/64/1f525.png"' +
'class="emoji" data-codepoints="1f525" title=":fire:">'
);
};

8
ts/types/Util.ts Normal file
View file

@ -0,0 +1,8 @@
export type RenderTextCallback = (
options: {
text: string;
key: number;
}
) => JSX.Element;
export type Localizer = (key: string, values?: Array<string>) => string;