diff --git a/ts/components/conversation/AddNewLines.md b/ts/components/conversation/AddNewLines.md new file mode 100644 index 00000000000..14d2b35ec64 --- /dev/null +++ b/ts/components/conversation/AddNewLines.md @@ -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} +/>; +``` diff --git a/ts/components/conversation/AddNewLines.tsx b/ts/components/conversation/AddNewLines.tsx index 7e9647b9b88..b4bdfe13528 100644 --- a/ts/components/conversation/AddNewLines.tsx +++ b/ts/components/conversation/AddNewLines.tsx @@ -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; } } diff --git a/ts/components/conversation/ContactDetail.tsx b/ts/components/conversation/ContactDetail.tsx index 4437f6559d0..1ea63b65af9 100644 --- a/ts/components/conversation/ContactDetail.tsx +++ b/ts/components/conversation/ContactDetail.tsx @@ -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; diff --git a/ts/components/conversation/Emojify.md b/ts/components/conversation/Emojify.md new file mode 100644 index 00000000000..17e2c9e1a6c --- /dev/null +++ b/ts/components/conversation/Emojify.md @@ -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} />; +``` diff --git a/ts/components/conversation/Emojify.tsx b/ts/components/conversation/Emojify.tsx index 6a323d95999..591b30522fa 100644 --- a/ts/components/conversation/Emojify.tsx +++ b/ts/components/conversation/Emojify.tsx @@ -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; } } diff --git a/ts/components/conversation/Linkify.md b/ts/components/conversation/Linkify.md new file mode 100644 index 00000000000..aa53d40c2da --- /dev/null +++ b/ts/components/conversation/Linkify.md @@ -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} />; +``` diff --git a/ts/components/conversation/Linkify.tsx b/ts/components/conversation/Linkify.tsx new file mode 100644 index 00000000000..bc5217e02b5 --- /dev/null +++ b/ts/components/conversation/Linkify.tsx @@ -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; + } +} diff --git a/ts/components/conversation/MessageBody.md b/ts/components/conversation/MessageBody.md index be171b7ce29..8c1f3634904 100644 --- a/ts/components/conversation/MessageBody.md +++ b/ts/components/conversation/MessageBody.md @@ -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 /> ``` diff --git a/ts/components/conversation/MessageBody.tsx b/ts/components/conversation/MessageBody.tsx index 703d902c9ee..058c79368f8 100644 --- a/ts/components/conversation/MessageBody.tsx +++ b/ts/components/conversation/MessageBody.tsx @@ -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>; } } diff --git a/ts/styleguide/StyleGuideUtil.ts b/ts/styleguide/StyleGuideUtil.ts index fdc9d628a08..d7ec2528549 100644 --- a/ts/styleguide/StyleGuideUtil.ts +++ b/ts/styleguide/StyleGuideUtil.ts @@ -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:">' - ); -}; diff --git a/ts/types/Util.ts b/ts/types/Util.ts new file mode 100644 index 00000000000..9819b5a8547 --- /dev/null +++ b/ts/types/Util.ts @@ -0,0 +1,8 @@ +export type RenderTextCallback = ( + options: { + text: string; + key: number; + } +) => JSX.Element; + +export type Localizer = (key: string, values?: Array<string>) => string;