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`