From 292e33c105d698b157f0555ad4e5e363c7acbb6d Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 22 Mar 2018 23:15:21 +0100 Subject: [PATCH] Implement AdminCommentContent for text and html --- .../coral-admin/src/components/IfHasLink.js | 2 +- .../src/components/UserDetailComment.js | 4 +- .../routes/Moderation/components/Comment.js | 4 +- .../contentRenderer.js} | 83 +++---- client/coral-admin/src/utils/index.js | 9 - .../components/AdminCommentContent.css | 12 + .../components/AdminCommentContent.js | 210 ++++++++++++++++++ client/coral-framework/utils/matchLinks.js | 8 + plugin-api/beta/client/components/index.js | 3 + .../client/components/AdminCommentContent.css | 4 + .../client/components/AdminCommentContent.js | 27 +++ .../client/containers/AdminCommentContent.js | 12 + plugins/talk-plugin-rich-text/client/index.js | 6 +- 13 files changed, 321 insertions(+), 63 deletions(-) rename client/coral-admin/src/{components/CommentFormatter.js => utils/contentRenderer.js} (59%) create mode 100644 client/coral-framework/components/AdminCommentContent.css create mode 100644 client/coral-framework/components/AdminCommentContent.js create mode 100644 client/coral-framework/utils/matchLinks.js create mode 100644 plugins/talk-plugin-rich-text/client/components/AdminCommentContent.css create mode 100644 plugins/talk-plugin-rich-text/client/components/AdminCommentContent.js create mode 100644 plugins/talk-plugin-rich-text/client/containers/AdminCommentContent.js diff --git a/client/coral-admin/src/components/IfHasLink.js b/client/coral-admin/src/components/IfHasLink.js index 8209a28e5..35be8c967 100644 --- a/client/coral-admin/src/components/IfHasLink.js +++ b/client/coral-admin/src/components/IfHasLink.js @@ -1,5 +1,5 @@ import React from 'react'; -import { matchLinks } from '../utils'; +import matchLinks from 'coral-framework/utils/matchLinks'; export default ({ text, children }) => { const hasLinks = !!matchLinks(text); diff --git a/client/coral-admin/src/components/UserDetailComment.js b/client/coral-admin/src/components/UserDetailComment.js index 72c85c960..f2ff2e932 100644 --- a/client/coral-admin/src/components/UserDetailComment.js +++ b/client/coral-admin/src/components/UserDetailComment.js @@ -5,7 +5,7 @@ import { Link } from 'react-router'; import { Icon } from 'coral-ui'; import CommentDetails from './CommentDetails'; import styles from './UserDetailComment.css'; -import CommentFormatter from 'coral-admin/src/components/CommentFormatter'; +import AdminCommentContent from 'coral-framework/components/AdminCommentContent'; import IfHasLink from 'coral-admin/src/components/IfHasLink'; import cn from 'classnames'; import CommentAnimatedEdit from './CommentAnimatedEdit'; @@ -93,7 +93,7 @@ class UserDetailComment extends React.Component { 'talk-admin-user-detail-comment' )} size={1} - defaultComponent={CommentFormatter} + defaultComponent={AdminCommentContent} passthrough={slotPassthrough} />
diff --git a/client/coral-admin/src/components/CommentFormatter.js b/client/coral-admin/src/utils/contentRenderer.js similarity index 59% rename from client/coral-admin/src/components/CommentFormatter.js rename to client/coral-admin/src/utils/contentRenderer.js index 8874d123f..9e5f4e1a4 100644 --- a/client/coral-admin/src/components/CommentFormatter.js +++ b/client/coral-admin/src/utils/contentRenderer.js @@ -1,6 +1,5 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { matchLinks } from '../utils'; +import { matchLinks } from './index'; import memoize from 'lodash/memoize'; function escapeRegExp(string) { @@ -35,6 +34,17 @@ function getPhrasesRegexp(suspectWords, bannedWords) { // Memoized version as arguments rarely change. const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp); +function nl2br(body, keyPrefix) { + const tokens = body.split('\n').reduce((tokens, t, i) => { + if (i !== 0) { + tokens.push(
); + } + tokens.push(t); + return tokens; + }, []); + return tokens; +} + // markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning // an array of React Elements. function markPhrases(body, suspectWords, bannedWords, keyPrefix) { @@ -48,14 +58,20 @@ function markPhrases(body, suspectWords, bannedWords, keyPrefix) { // markLinks looks for links inside `body` and highlights them by returning // an array of React Elements. -function markLinks(body) { +function markLinks(body, keyPrefix) { const matches = matchLinks(body); const content = []; let index = 0; if (matches) { matches.forEach((match, i) => { content.push(body.substring(index, match.index)); - content.push({match.text}); + content.push( + +
+ {match.text} + + + ); index = match.lastIndex; }); } @@ -63,47 +79,20 @@ function markLinks(body) { return content; } -const CommentFormatter = ({ - body, - suspectWords, - bannedWords, - className = 'comment', - ...rest -}) => { - // Breaking the body by line break - const textbreaks = body.split('\n'); +export function renderText(body, suspectWords, bannedWords) { + return nl2br(body).map((element, index) => { + // Skip br tags. + if (typeof element !== 'string') { + return element; + } + return markLinks(element, index).map((element, index) => { + // Keep highlighted links. + if (typeof element !== 'string') { + return element; + } - return ( - - {textbreaks.map((line, i) => { - const content = markLinks(line).map((element, index) => { - // Keep highlighted links. - if (typeof element !== 'string') { - return element; - } - - // Highlight suspect and banned phrase inside this part of text. - return markPhrases(element, suspectWords, bannedWords, index); - }); - - return ( - - {content} - {i !== textbreaks.length - 1 && ( -
- )} -
- ); - })} -
- ); -}; - -CommentFormatter.propTypes = { - className: PropTypes.string, - bannedWords: PropTypes.array, - suspectWords: PropTypes.array, - body: PropTypes.string, -}; - -export default CommentFormatter; + // Highlight suspect and banned phrase inside this part of text. + return markPhrases(element, suspectWords, bannedWords, index); + }); + }); +} diff --git a/client/coral-admin/src/utils/index.js b/client/coral-admin/src/utils/index.js index 41ff3db3c..180615a87 100644 --- a/client/coral-admin/src/utils/index.js +++ b/client/coral-admin/src/utils/index.js @@ -1,12 +1,3 @@ -import LinkifyIt from 'linkify-it'; -import tlds from 'tlds'; -const linkify = new LinkifyIt(); -linkify.tlds(tlds); - -export function matchLinks(text) { - return linkify.match(text); -} - export const isPremod = mod => mod === 'PRE'; export const getModPath = (type = 'all', assetId) => diff --git a/client/coral-framework/components/AdminCommentContent.css b/client/coral-framework/components/AdminCommentContent.css new file mode 100644 index 000000000..4acd88a28 --- /dev/null +++ b/client/coral-framework/components/AdminCommentContent.css @@ -0,0 +1,12 @@ +.content { + a { + color: #063b9a; + text-decoration: underline; + font-weight: 300; + background-color: ##f4ff81; + } + + mark { + background-color: ##f4ff81; + } +} diff --git a/client/coral-framework/components/AdminCommentContent.js b/client/coral-framework/components/AdminCommentContent.js new file mode 100644 index 000000000..be86c26bb --- /dev/null +++ b/client/coral-framework/components/AdminCommentContent.js @@ -0,0 +1,210 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import matchLinks from '../utils/matchLinks'; +import memoize from 'lodash/memoize'; +import cn from 'classnames'; +import styles from './AdminCommentContent.css'; + +function escapeHTML(unsafe) { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +// generate a regulare expression that catches the `phrases`. +function generateRegExp(phrases) { + const inner = phrases + .map(phrase => + phrase + .split(/\s+/) + .map(word => escapeRegExp(word)) + .join('[\\s"?!.]+') + ) + .join('|'); + + const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`; + try { + return new RegExp(pattern, 'iu'); + } catch (_err) { + // IE does not support unicode support, so we'll create one without. + return new RegExp(pattern, 'i'); + } +} + +// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases. +function getPhrasesRegexp(suspectWords, bannedWords) { + return generateRegExp([...suspectWords, ...bannedWords]); +} + +// Memoized version as arguments rarely change. +const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp); + +function nl2br(body, keyPrefix) { + const tokens = body.split('\n').reduce((tokens, t, i) => { + if (i !== 0) { + tokens.push(
); + } + tokens.push(t); + return tokens; + }, []); + return tokens; +} + +// markPhrases looks for `supsectWords` and `bannedWords` inside `body` and highlights them by returning +// an array of React Elements. +function markPhrases(body, suspectWords, bannedWords, keyPrefix) { + const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords); + const tokens = body.split(regexp); + return tokens.map( + (token, i) => + i % 3 === 2 ? {token} : token + ); +} + +// markLinks looks for links inside `body` and highlights them by returning +// an array of React Elements. +function markLinks(body, keyPrefix) { + const matches = matchLinks(body); + const content = []; + let index = 0; + if (matches) { + matches.forEach((match, i) => { + content.push(body.substring(index, match.index)); + content.push( + + {match.text} + + ); + index = match.lastIndex; + }); + } + content.push(body.substring(index)); + return content; +} + +// markPhrasesHTML looks for `supsectWords` and `bannedWords` inside `text` and highlights them by returning +// a HTML string. +function markPhrasesHTML(text, suspectWords, bannedWords) { + const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords); + const tokens = text.split(regexp); + if (tokens.length === 1) { + return text; + } + return tokens + .map( + (token, i) => + i % 3 === 2 ? `${escapeHTML(token)}` : escapeHTML(token) + ) + .join(''); +} + +// markHTMLNode manipulates the node by looking for #text nodes and adding markers +// for `supsectWords` and `bannedWords`. +function markHTMLNode(parentNode, suspectWords, bannedWords) { + parentNode.childNodes.forEach(node => { + if (node.nodeName === '#text') { + const newContent = markPhrasesHTML( + node.textContent, + suspectWords, + bannedWords + ); + if (newContent !== node.textContent) { + const newNode = document.createElement('span'); + newNode.innerHTML = newContent; + parentNode.replaceChild(newNode, node); + } + } else { + markHTMLNode(node, suspectWords, bannedWords); + } + }); +} + +// renderText performs all the marking of a text body and returns an array of React Elements. +function renderText(body, suspectWords, bannedWords) { + return nl2br(body).map((element, index) => { + // Skip br tags. + if (typeof element !== 'string') { + return element; + } + return markLinks(element, index).map((element, index) => { + // Keep highlighted links. + if (typeof element !== 'string') { + return element; + } + + // Highlight suspect and banned phrase inside this part of text. + return markPhrases(element, suspectWords, bannedWords, index); + }); + }); +} + +const commonPropTypes = { + className: PropTypes.string, + bannedWords: PropTypes.array.isRequired, + suspectWords: PropTypes.array.isRequired, + body: PropTypes.string.isRequired, +}; + +const AdminCommentContentText = ({ + body, + className, + suspectWords, + bannedWords, +}) => { + return ( +
+ {renderText(body, suspectWords, bannedWords)} +
+ ); +}; +AdminCommentContentText.propTypes = commonPropTypes; + +const AdminCommentContentHTML = ({ + body, + className, + suspectWords, + bannedWords, +}) => { + const node = document.createElement('div'); + node.innerHTML = body; + markHTMLNode(node, suspectWords, bannedWords); + return ( +
+ ); +}; +AdminCommentContentHTML.propTypes = commonPropTypes; + +const AdminCommentContent = ({ + className, + body, + suspectWords, + bannedWords, + html, +}) => { + const Component = html ? AdminCommentContentHTML : AdminCommentContentText; + return ( + + ); +}; + +AdminCommentContent.propTypes = { + ...commonPropTypes, + html: PropTypes.bool, +}; + +export default AdminCommentContent; diff --git a/client/coral-framework/utils/matchLinks.js b/client/coral-framework/utils/matchLinks.js new file mode 100644 index 000000000..8387d64cf --- /dev/null +++ b/client/coral-framework/utils/matchLinks.js @@ -0,0 +1,8 @@ +import LinkifyIt from 'linkify-it'; +import tlds from 'tlds'; +const linkify = new LinkifyIt(); +linkify.tlds(tlds); + +export default function matchLinks(text) { + return linkify.match(text); +} diff --git a/plugin-api/beta/client/components/index.js b/plugin-api/beta/client/components/index.js index fc6159544..77b1a5517 100644 --- a/plugin-api/beta/client/components/index.js +++ b/plugin-api/beta/client/components/index.js @@ -20,6 +20,9 @@ export { export { default as CommentContent, } from 'coral-framework/components/CommentContent'; +export { + default as AdminCommentContent, +} from 'coral-framework/components/AdminCommentContent'; export { default as ConfigureCard, } from 'coral-framework/components/ConfigureCard'; diff --git a/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.css b/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.css new file mode 100644 index 000000000..046eadfe7 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.css @@ -0,0 +1,4 @@ +.content { + composes: content from "./CommentContent.css"; +} + diff --git a/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.js b/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.js new file mode 100644 index 000000000..1b7e53ebf --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/AdminCommentContent.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './AdminCommentContent.css'; +import { AdminCommentContent as Content } from 'plugin-api/beta/client/components'; + +class AdminCommentContent extends React.Component { + render() { + const { comment, suspectWords, bannedWords } = this.props; + return ( + + ); + } +} + +AdminCommentContent.propTypes = { + comment: PropTypes.object.isRequired, + suspectWords: PropTypes.array.isRequired, + bannedWords: PropTypes.array.isRequired, +}; + +export default AdminCommentContent; diff --git a/plugins/talk-plugin-rich-text/client/containers/AdminCommentContent.js b/plugins/talk-plugin-rich-text/client/containers/AdminCommentContent.js new file mode 100644 index 000000000..bfc708f7b --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/containers/AdminCommentContent.js @@ -0,0 +1,12 @@ +import { gql } from 'react-apollo'; +import { withFragments } from 'plugin-api/beta/client/hocs'; +import AdminCommentContent from '../components/AdminCommentContent'; + +export default withFragments({ + comment: gql` + fragment TalkPluginRichText_AdminCommentContent_comment on Comment { + body + richTextBody + } + `, +})(AdminCommentContent); diff --git a/plugins/talk-plugin-rich-text/client/index.js b/plugins/talk-plugin-rich-text/client/index.js index 6eb7a8c95..eb768adb9 100644 --- a/plugins/talk-plugin-rich-text/client/index.js +++ b/plugins/talk-plugin-rich-text/client/index.js @@ -1,13 +1,15 @@ import Editor from './containers/Editor'; import CommentContent from './containers/CommentContent'; +import AdminCommentContent from './containers/AdminCommentContent'; + import { gql } from 'react-apollo'; export default { slots: { draftArea: [Editor], commentContent: [CommentContent], - adminCommentContent: [CommentContent], - userDetailCommentContent: [CommentContent], + adminCommentContent: [AdminCommentContent], + userDetailCommentContent: [AdminCommentContent], }, fragments: { CreateCommentResponse: gql`