Implement AdminCommentContent for text and html

This commit is contained in:
Chi Vinh Le
2018-03-22 23:15:21 +01:00
parent a1a867ec55
commit 292e33c105
13 changed files with 321 additions and 63 deletions
@@ -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);
@@ -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}
/>
<a
@@ -8,7 +8,7 @@ import styles from './Comment.css';
import CommentLabels from 'coral-admin/src/components/CommentLabels';
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
import Slot from 'coral-framework/components/Slot';
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 ApproveButton from 'coral-admin/src/components/ApproveButton';
@@ -140,7 +140,7 @@ class Comment extends React.Component {
fill="adminCommentContent"
className={cn(styles.commentContent, 'talk-admin-comment')}
size={1}
defaultComponent={CommentFormatter}
defaultComponent={AdminCommentContent}
passthrough={{ ...slotPassthrough, ...formatterSettings }}
/>
<div className={styles.commentContentFooter}>
@@ -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(<br key={`${keyPrefix}_${i}`} />);
}
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(<mark key={i}>{match.text}</mark>);
content.push(
<mark key={`${keyPrefix}_${i}`}>
<a href={match.url} target="_blank">
{match.text}
</a>
</mark>
);
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 (
<span className={`${className}-text`} {...rest}>
{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 (
<span key={i} className={`${className}-line`}>
{content}
{i !== textbreaks.length - 1 && (
<br className={`${className}-linebreak`} />
)}
</span>
);
})}
</span>
);
};
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);
});
});
}
-9
View File
@@ -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) =>
@@ -0,0 +1,12 @@
.content {
a {
color: #063b9a;
text-decoration: underline;
font-weight: 300;
background-color: ##f4ff81;
}
mark {
background-color: ##f4ff81;
}
}
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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(<br key={`${keyPrefix}_${i}`} />);
}
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 ? <mark key={`${keyPrefix}_${i}`}>{token}</mark> : 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(
<a key={`${keyPrefix}_${i}`} href={match.url} target="_blank">
{match.text}
</a>
);
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 ? `<mark>${escapeHTML(token)}</mark>` : 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 (
<div className={cn(className, styles.content)}>
{renderText(body, suspectWords, bannedWords)}
</div>
);
};
AdminCommentContentText.propTypes = commonPropTypes;
const AdminCommentContentHTML = ({
body,
className,
suspectWords,
bannedWords,
}) => {
const node = document.createElement('div');
node.innerHTML = body;
markHTMLNode(node, suspectWords, bannedWords);
return (
<div
className={cn(className, styles.content)}
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
/>
);
};
AdminCommentContentHTML.propTypes = commonPropTypes;
const AdminCommentContent = ({
className,
body,
suspectWords,
bannedWords,
html,
}) => {
const Component = html ? AdminCommentContentHTML : AdminCommentContentText;
return (
<Component
className={className}
body={body}
suspectWords={suspectWords}
bannedWords={bannedWords}
/>
);
};
AdminCommentContent.propTypes = {
...commonPropTypes,
html: PropTypes.bool,
};
export default AdminCommentContent;
@@ -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);
}
@@ -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';
@@ -0,0 +1,4 @@
.content {
composes: content from "./CommentContent.css";
}
@@ -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 (
<Content
className={styles.content}
body={comment.richTextBody ? comment.richTextBody : comment.body}
suspectWords={suspectWords}
bannedWords={bannedWords}
html={!!comment.richTextBody}
/>
);
}
}
AdminCommentContent.propTypes = {
comment: PropTypes.object.isRequired,
suspectWords: PropTypes.array.isRequired,
bannedWords: PropTypes.array.isRequired,
};
export default AdminCommentContent;
@@ -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);
@@ -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`