diff --git a/client/coral-admin/src/components/CommentFormatter.js b/client/coral-admin/src/components/CommentFormatter.js deleted file mode 100644 index 8874d123f..000000000 --- a/client/coral-admin/src/components/CommentFormatter.js +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { matchLinks } from '../utils'; -import memoize from 'lodash/memoize'; - -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); - -// 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) { - 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; -} - -const CommentFormatter = ({ - body, - suspectWords, - bannedWords, - className = 'comment', - ...rest -}) => { - // Breaking the body by line break - const textbreaks = body.split('\n'); - - 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; 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/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-embed-stream/src/tabs/profile/components/Comment.css b/client/coral-embed-stream/src/tabs/profile/components/Comment.css index 40a003de1..9004a1e6c 100644 --- a/client/coral-embed-stream/src/tabs/profile/components/Comment.css +++ b/client/coral-embed-stream/src/tabs/profile/components/Comment.css @@ -15,6 +15,7 @@ .main { min-width: 70%; + max-width: 100%; } .sidebar { diff --git a/client/coral-framework/components/AdminCommentContent.css b/client/coral-framework/components/AdminCommentContent.css new file mode 100644 index 000000000..9b7a47b33 --- /dev/null +++ b/client/coral-framework/components/AdminCommentContent.css @@ -0,0 +1,16 @@ +.content { + a { + color: #063b9a; + text-decoration: underline; + font-weight: 300; + background-color: #f4ff81; + } + + mark { + background-color: #f4ff81; + } + + b, strong { + font-weight: 600; + } +} diff --git a/client/coral-framework/components/AdminCommentContent.js b/client/coral-framework/components/AdminCommentContent.js new file mode 100644 index 000000000..b3110e822 --- /dev/null +++ b/client/coral-framework/components/AdminCommentContent.js @@ -0,0 +1,217 @@ +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, +}) => { + // We create a Shadow DOM Tree with the HTML body content and + // use it as a parser. + const node = document.createElement('div'); + node.innerHTML = body; + + // Then we traverse it recursively and manipulate it to highlight suspect words + // and banned words. + markHTMLNode(node, suspectWords, bannedWords); + + // Finally we render the content of the Shadow DOM Tree + 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/docs/_config.yml b/docs/_config.yml index f269d8695..c61fdeb21 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -154,6 +154,8 @@ sidebar: url: /building-basic-plugin/ - title: Customizing Plugins with Coral UI url: /customizing-plugins-coral-ui/ + - title: When You've Installed Talk + url: /when-youve-installed-talk/ - title: Migrating children: - title: Migrating to v4.0.0 @@ -171,7 +173,7 @@ marked: breaks: false smartLists: true smartypants: true - modifyAnchors: '' + modifyAnchors: 1 autolink: true node_sass: diff --git a/docs/source/01-01-talk-quickstart.md b/docs/source/01-01-talk-quickstart.md index 9ab7a50af..c18aea638 100644 --- a/docs/source/01-01-talk-quickstart.md +++ b/docs/source/01-01-talk-quickstart.md @@ -24,7 +24,7 @@ to persist data. The following versions are supported: An optional dependency for Talk is [Docker](https://www.docker.com/community-edition#/download). -It is used during [development](#development) to set up the database and can be +It is used during development to set up the database and can be used to [install via Docker](#installation-from-docker). We have tested Talk and this documentation with versions 17.06.2+. @@ -80,7 +80,7 @@ volumes: ``` This is the bare minimum needed to run the demo, for more configuration -variables, check out the [Configuration](./configuration/) section. +variables, check out the [Configuration](/talk/configuration/) section. And you can then start it with: @@ -172,7 +172,7 @@ TALK_FACEBOOK_APP_SECRET=A-Facebook-App-Secret ``` This is only the bare minimum needed to run the demo, for more configuration -variables, check out the [Configuration](./configuration/) section. Facebook login above +variables, check out the [Configuration](/talk/configuration/) section. Facebook login above will definitely not work unless you change those values as well. diff --git a/docs/source/01-02-installation-from-docker.md b/docs/source/01-02-installation-from-docker.md index 13e253737..dbf43fb54 100644 --- a/docs/source/01-02-installation-from-docker.md +++ b/docs/source/01-02-installation-from-docker.md @@ -75,7 +75,7 @@ volumes: ``` This is the bare minimum needed to start Talk, for more configuration -variables, check out the [Configuration](./configuration/) section. +variables, check out the [Configuration](/talk/configuration/) section. And you can then start it with: @@ -111,7 +111,7 @@ talk_1 yarn start Up 0.0.0.0:3000->3000/tcp ``` -At this stage, you should refer to the [configuration](./configuration/) for +At this stage, you should refer to the [configuration](/talk/configuration/) for configuration variables that are specific to your installation. ## Onbuild @@ -142,7 +142,7 @@ This accomplishes a lot: 2. Installs any new dependencies that were required by any new plugins. 3. Builds the new static bundles so that they are ready to serve when the image is running. -4. Specifies a build time variable [TALK_DEFAULT_LANG](./advanced-configuration/#talk_default_lang). Refer +4. Specifies a build time variable [TALK_DEFAULT_LANG](/talk/advanced-configuration/#talk-default-lang). Refer to [Dockerfile.onbuild](https://github.com/coralproject/talk/blob/master/Dockerfile.onbuild) for the available build variables. diff --git a/docs/source/01-03-installation-from-source.md b/docs/source/01-03-installation-from-source.md index 8b6f52228..a460751f7 100644 --- a/docs/source/01-03-installation-from-source.md +++ b/docs/source/01-03-installation-from-source.md @@ -62,7 +62,7 @@ TALK_FACEBOOK_APP_SECRET=A-Facebook-App-Secret ``` This is the bare minimum needed to start Talk, for more configuration -variables, check out the [Configuration](./configuration/) +variables, check out the [Configuration](/talk/configuration/) section. Facebook login above will definitely not work unless you change those values as well. @@ -73,5 +73,5 @@ You can now start the application by running: yarn watch:server ``` -At this stage, you should refer to the [configuration](./configuration/) for +At this stage, you should refer to the [configuration](/talk/configuration/) for configuration variables that are specific to your installation. diff --git a/docs/source/02-01-required-configuration.md b/docs/source/02-01-required-configuration.md index 1bfaa361c..be1e1d0b2 100644 --- a/docs/source/02-01-required-configuration.md +++ b/docs/source/02-01-required-configuration.md @@ -16,7 +16,7 @@ instance of Talk. If you've already configured your application with the required configuration, you can further customize it's behavior by applying -[Advanced Configuration](./advanced-configuration/). +[Advanced Configuration](/talk/advanced-configuration/). ## TALK_MONGO_URL @@ -81,4 +81,4 @@ TALK_JWT_SECRET=jX9y8G2ApcVLwyL{$6s3 Be default, we sign our tokens with HMAC using a SHA-256 hash algorithm. If you want to change the signing algorithm, or use multiple signing/verifying keys, -refer to our [Advanced Configuration](./advanced-configuration/) documentation. +refer to our [Advanced Configuration](/talk/advanced-configuration/) documentation. diff --git a/docs/source/02-02-advanced-configuration.md b/docs/source/02-02-advanced-configuration.md index 4b33134bf..b2ad29ad0 100644 --- a/docs/source/02-02-advanced-configuration.md +++ b/docs/source/02-02-advanced-configuration.md @@ -15,7 +15,7 @@ The variables above have defaults, and are _optional_ to start your instance of Talk. If this is your first time configuring Talk, ensure you've also added the -[Required Configuration](./configuration) as well, +[Required Configuration](/talk/configuration) as well, otherwise the application will fail to start. ## TALK_CACHE_EXPIRY_COMMENT_COUNT @@ -26,7 +26,7 @@ Configure the duration for which comment counts are cached for, parsed by ## TALK_DEFAULT_LANG This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild](./installation-from-docker/#onbuild) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_DEFAULT_LANG=en`. Specify the default translation language. (Default `en`) @@ -34,7 +34,7 @@ Specify the default translation language. (Default `en`) ## TALK_DEFAULT_STREAM_TAB This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild](./installation-from-docker/#onbuild) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_DEFAULT_STREAM_TAB=all`. Specify the default stream tab in the admin. (Default `all`) @@ -53,7 +53,7 @@ in the embed.js target that is loaded on the page that loads the embed. (Default ## TALK_DISABLE_STATIC_SERVER When `TRUE`, it will not mount the static asset serving routes on the router. -This is used primarily in conjunction with [TALK_STATIC_URI](#talk_static_uri) +This is used primarily in conjunction with [TALK_STATIC_URI](#talk-static-uri) when the static assets are being hosted on an external domain. (Default `FALSE`) ## TALK_HELMET_CONFIGURATION @@ -149,7 +149,7 @@ claim for login JWT tokens. (Default `talk`) ## TALK_JWT_CLEAR_COOKIE_LOGOUT When `FALSE`, Talk will not clear the cookie with name -[TALK_JWT_SIGNING_COOKIE_NAME](#talk_jwt_signing_cookie_name) when logging out +[TALK_JWT_SIGNING_COOKIE_NAME](#talk-jwt-signing-cookie-name) when logging out but will still blacklist the token. (Default `TRUE`) ## TALK_JWT_COOKIE_NAME @@ -160,8 +160,8 @@ user. (Default `authorization`) ## TALK_JWT_COOKIE_NAMES The different cookie names to check for a JWT token in, separated by a `,`. By -default, we always use the value of [TALK_JWT_COOKIE_NAME](#talk_jwt_cookie_name) -and [TALK_JWT_SIGNING_COOKIE_NAME](#talk_jwt_signing_cookie_name) for this +default, we always use the value of [TALK_JWT_COOKIE_NAME](#talk-jwt-cookie-name) +and [TALK_JWT_SIGNING_COOKIE_NAME](#talk-jwt-signing-cookie-name) for this value. Any additional cookie names specified here will be appended to the list of cookie names to inspect. @@ -183,13 +183,13 @@ Would mean we would check the following cookies (in order) for a valid token: When `TRUE`, Talk will not verify or sign JWT’s with an audience [aud](https://tools.ietf.org/html/rfc7519#section-4.1.3) -claim, even if [TALK_JWT_AUDIENCE](#talk_jwt_audience) is set. (Default `FALSE`) +claim, even if [TALK_JWT_AUDIENCE](#talk-jwt-audience) is set. (Default `FALSE`) ## TALK_JWT_DISABLE_ISSUER When `TRUE`, Talk will not verify or sign JWT’s with an issuer [iss](https://tools.ietf.org/html/rfc7519#section-4.1.1) -claim, even if [TALK_JWT_ISSUER](#talk_jwt_issuer) is set. (Default `FALSE`) +claim, even if [TALK_JWT_ISSUER](#talk-jwt-issuer) is set. (Default `FALSE`) ## TALK_JWT_EXPIRY @@ -205,7 +205,7 @@ reason to create reasonable expiry lengths as to minimize the storage overhead. ## TALK_JWT_ISSUER The issuer [iss](https://tools.ietf.org/html/rfc7519#section-4.1.1) -claim for login JWT tokens. (Defaults to value of [TALK_ROOT_URL](./configuration/#talk_root_url)) +claim for login JWT tokens. (Defaults to value of [TALK_ROOT_URL](/talk/configuration/#talk-root-url)) ## TALK_JWT_SECRET @@ -223,22 +223,25 @@ You can also express this secret in the JSON syntax: TALK_JWT_SECRET={"secret": "jX9y8G2ApcVLwyL{$6s3"} ``` -Refer to the documentation for [TALK_JWT_ALG](#talk_jwt_alg) for other signing +Refer to the documentation for [TALK_JWT_ALG](#talk-jwt-alg) for other signing methods and other forms of the `TALK_JWT_SECRET`. If you are interested in using -multiple keys, then refer to [TALK_JWT_SECRETS](#talk_jwt_secrets). +multiple keys, then refer to [TALK_JWT_SECRETS](#talk-jwt-secrets). ## TALK_JWT_SECRETS Used when specifying multiple secrets used for key rotations. This is a JSON encoded array, where each element matches the JWT Secret pattern. When this is -used, you do not need to specify a [TALK_JWT_SECRET](#talk_jwt_secret) as this +used, you do not need to specify a [TALK_JWT_SECRET](#talk-jwt-secret) as this will take precedence. **The first secret in `TALK_JWT_SECRETS` will be used for signing, and must contain a private key if used with an asymmetric algorithm.** All secrets should specify a `kid` field which uniquely identifies a given key -and will sign all tokens with that `kid` for later identification. +and will sign all tokens with that `kid` for later identification. _If a token +is not signed with the `kid` field in the header, and multiple secrets are used, +the token will fail to be verified. This field must match what's provided to +Talk in the form of the `kid` field in the secret._ -When the value of [TALK_JWT_ALG](#talk_jwt_alg) is a `HS*` value, then the value +When the value of [TALK_JWT_ALG](#talk-jwt-alg) is a `HS*` value, then the value of the `TALK_JWT_SECRETS` should take the form: ```plain @@ -247,24 +250,24 @@ TALK_JWT_SECRETS=[{"kid": "1", "secret": "my-super-secret"}, {"kid": "2", "secre Note that the secret is stored in a JSON object, keyed by `secret`. This is only needed when specifying in the multiple secrets for `TALK_JWT_SECRETS`, but may -be used to specify the single [TALK_JWT_SECRET](#talk_jwt_secret). +be used to specify the single [TALK_JWT_SECRET](#talk-jwt-secret). -When the value of [TALK_JWT_ALG](#talk_jwt_alg) is **not** a `HS*` value, then +When the value of [TALK_JWT_ALG](#talk-jwt-alg) is **not** a `HS*` value, then the value of the `TALK_JWT_SECRETS` should take the form: ```plain TALK_JWT_SECRETS=[{"kid": "1", "private": "", "public": ""}, ...] ``` -Refer to the documentation on the [TALK_JWT_ALG](#talk_jwt_alg) for more +Refer to the documentation on the [TALK_JWT_ALG](#talk-jwt-alg) for more information on what to store in these parameters. ## TALK_JWT_SIGNING_COOKIE_NAME The default cookie name that is use to set a cookie containing a JWT that was -issued by Talk. (Defaults to value of [TALK_JWT_COOKIE_NAME](#talk_jwt_cookie_name)) +issued by Talk. (Defaults to value of [TALK_JWT_COOKIE_NAME](#talk-jwt-cookie-name)) ## TALK_JWT_USER_ID_CLAIM @@ -298,8 +301,8 @@ the websocket to keep the socket alive, parsed by Setting a reCAPTCHA Public and Secret key will enable and require reCAPTCHA upon multiple failed login attempts. Client secret used for enabling reCAPTCHA powered logins. If -[TALK_RECAPTCHA_SECRET](#talk_recaptcha_secret) and -[TALK_RECAPTCHA_PUBLIC](#talk_recaptcha_public) are not provided it will instead +[TALK_RECAPTCHA_SECRET](#talk-recaptcha-secret) and +[TALK_RECAPTCHA_PUBLIC](#talk-recaptcha-public) are not provided it will instead default to providing only a time based lockout. Refer to [reCAPTCHA](https://www.google.com/recaptcha/intro/index.html) for information on getting an account setup. @@ -307,8 +310,8 @@ on getting an account setup. ## TALK_RECAPTCHA_SECRET Server secret used for enabling reCAPTCHA powered logins. If -[TALK_RECAPTCHA_SECRET](#talk_recaptcha_secret) and -[TALK_RECAPTCHA_PUBLIC](#talk_recaptcha_public) are not provided it will instead +[TALK_RECAPTCHA_SECRET](#talk-recaptcha-secret) and +[TALK_RECAPTCHA_PUBLIC](#talk-recaptcha-public) are not provided it will instead default to providing only a time based lockout. Refer to [reCAPTCHA](https://www.google.com/recaptcha/intro/index.html) for information on getting an account setup. @@ -347,7 +350,7 @@ by [ms](https://www.npmjs.com/package/ms). (Default `1 sec`) ## TALK_ROOT_URL_MOUNT_PATH When set to `TRUE`, the routes will be mounted onto the `` component -of the [TALK_ROOT_URL](./configuration/#talk_root_url). +of the [TALK_ROOT_URL](/talk/configuration/#talk-root-url). You would use this when your upstream proxy cannot strip the prefix from the url. (Default `FALSE`) @@ -395,12 +398,12 @@ Used to set the uri where the static assets should be served from. This is used when you want to upload the static assets through your build process to a service like Google Cloud Storage or Amazon S3 and you would then specify the CDN/Storage url. (Defaults to value of -[TALK_ROOT_URL](./configuration/#talk_root_url)) +[TALK_ROOT_URL](/talk/configuration/#talk-root-url)) ## TALK_THREADING_LEVEL This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild](./installation-from-docker/#onbuild) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_THREADING_LEVEL=3`. Specify the maximum depth of the comment thread. (Default `3`) @@ -414,13 +417,13 @@ Used to override the location to connect to the websocket endpoint to potentially another host. This should be used when you need to route websocket requests out of your CDN in order to serve traffic more efficiently. -If the value of [TALK_ROOT_URL](./configuration/#talk_root_url) +If the value of [TALK_ROOT_URL](/talk/configuration/#talk-root-url) is a https url, then this defaults to `wss://${location.host}${MOUNT_PATH}api/v1/live`. Otherwise, it defaults to `ws://${location.host}${MOUNT_PATH}api/v1/live`. -Where `MOUNT_PATH` is either `/` if [TALK_ROOT_URL_MOUNT_PATH](#talk_root_url_mount_path) +Where `MOUNT_PATH` is either `/` if [TALK_ROOT_URL_MOUNT_PATH](#talk-root-url-mount-path) is `FALSE`, or the path component of -[TALK_ROOT_URL](./configuration/#talk_root_url) if it's `TRUE`. +[TALK_ROOT_URL](/talk/configuration/#talk-root-url) if it's `TRUE`. **Warning: if used without managing the auth state manually, auth cannot be persisted due to browser restrictions.** @@ -491,7 +494,7 @@ be used with caution. (Default `FALSE`) ## TALK_ADDTL_COMMENTS_ON_LOAD_MORE This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }}) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_ADDTL_COMMENTS_ON_LOAD_MORE=10`. Specifies the number of additional comments to load when a user clicks `Load More`. (Default `10`) @@ -499,7 +502,7 @@ Specifies the number of additional comments to load when a user clicks `Load Mor ## TALK_ASSET_COMMENTS_LOAD_DEPTH This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }}) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_ASSET_COMMENTS_LOAD_DEPTH=10`. Specifies the initial number of comments to load for an asset. (Default `10`) @@ -507,7 +510,7 @@ Specifies the initial number of comments to load for an asset. (Default `10`) ## TALK_REPLY_COMMENTS_LOAD_DEPTH This is a **Build Variable** and must be consumed during build. If using the -[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }}) +[Docker-onbuild](/talk/installation-from-docker/#onbuild) image you can specify it with `--build-arg TALK_REPLY_COMMENTS_LOAD_DEPTH=3`. Specifies the initial replies to load for a comment. (Default `3`) diff --git a/docs/source/03-01-product-guide-how-talk-works.md b/docs/source/03-01-product-guide-how-talk-works.md index fa27e5ed0..9463f74fe 100644 --- a/docs/source/03-01-product-guide-how-talk-works.md +++ b/docs/source/03-01-product-guide-how-talk-works.md @@ -28,13 +28,13 @@ Plugins are additional functionality which are optional to use with Talk. You can turn these on or off, depending on your specific needs. Plugins are either part of our core plugins, which ship with Talk, or they are developed by 3rd parties and either used privately and internally, or are open sourced for use -across the greater community. You can explore the plugins we offer by visiting our [Default Plugins](./default-plugins/) -and [Additional Plugins](./additional-plugins/) pages. +across the greater community. You can explore the plugins we offer by visiting our [Default Plugins](/talk/default-plugins/) +and [Additional Plugins](/talk/additional-plugins/) pages. ## Recipes Recipes are plugin templates that are created by the Talk team and 3rd party developers, in order to help contributors and newsrooms build plugins easily. -You can explore the recipes we offer by visiting our [Plugin Recipes](./plugin-recipes/) +You can explore the recipes we offer by visiting our [Plugin Recipes](/talk/plugin-recipes/) page. diff --git a/docs/source/03-02-product-guide-commenter-features.md b/docs/source/03-02-product-guide-commenter-features.md index 1c432303a..390fdce50 100644 --- a/docs/source/03-02-product-guide-commenter-features.md +++ b/docs/source/03-02-product-guide-commenter-features.md @@ -31,7 +31,7 @@ https://?commentId= Talk supports by default 3 levels of threading, meaning each top-level comment has a depth of 3 replies; replies beyond that are not nested below the 3rd level. You can adjust this using the -[TALK_THREADING_LEVEL](./advanced-configuration/#talk_threading_level) +[TALK_THREADING_LEVEL](/talk/advanced-configuration/#talk-threading-level) configuration variable. We don’t recommend deep threading because it can cause issues with styling, especially on mobile. @@ -162,7 +162,7 @@ Staff role. The Featured comment badge shows when a comment has been featured. Another optional badge is the Subscriber badge (which is available as a -[Recipe](./plugin-recipes/#recipe-subscriber). +[Recipe](/talk/plugin-recipes/#recipe-subscriber). Badges are another easy part of Talk to customize by creating a new `tag`, then setting some rules for when it should show, and how the badge should be styled. diff --git a/docs/source/03-03-product-guide-moderator-features.md b/docs/source/03-03-product-guide-moderator-features.md index 44f5605b4..9fbda6a18 100644 --- a/docs/source/03-03-product-guide-moderator-features.md +++ b/docs/source/03-03-product-guide-moderator-features.md @@ -56,8 +56,8 @@ history. **Toxic** The Toxic badge signifies comments that are above the set Toxicity Probability -Threshold. Note you must have [talk-plugin-toxic-comments](./additional-plugins/#talk-plugin-toxic-comments) enabled. -[Read more about Toxic Comments here](./toxic-comments/). +Threshold. Note you must have [talk-plugin-toxic-comments](/talk/additional-plugins/#talk-plugin-toxic-comments) enabled. +[Read more about Toxic Comments here](/talk/toxic-comments/). **Suspect** @@ -122,7 +122,7 @@ automatically. **Reports** This shows if a commenter is a reliable flagger, an unreliable flagger, or a -neutral flagger. [Read more about reliable and unreliable flaggers here](./trust/#reliable-and-unreliable-flaggers). +neutral flagger. [Read more about reliable and unreliable flaggers here](/talk/trust/#reliable-and-unreliable-flaggers). **Moderating from this View** @@ -173,7 +173,7 @@ manage your team members’ roles (Admins, Moderators, Staff), as well as search for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). ### Configure -See [Configuring Talk](./configuring-talk/). +See [Configuring Talk](/talk/configuring-talk/). ## Moderating via the Comment Stream diff --git a/docs/source/03-04-product-guide-trust.md b/docs/source/03-04-product-guide-trust.md index bea080716..c27c59dcc 100644 --- a/docs/source/03-04-product-guide-trust.md +++ b/docs/source/03-04-product-guide-trust.md @@ -28,7 +28,7 @@ Here are the default thresholds: +3 and higher: Reliable ``` -You can configure your own Trust thresholds by using [TRUST_THRESHOLD](./advanced-configuration/#trust_thresholds) in your +You can configure your own Trust thresholds by using [TRUST_THRESHOLD](/talk/advanced-configuration/#trust-thresholds) in your configuration. diff --git a/docs/source/03-05-product-guide-toxic-comments.md b/docs/source/03-05-product-guide-toxic-comments.md index 3d52bfe22..ddbb4f9ff 100644 --- a/docs/source/03-05-product-guide-toxic-comments.md +++ b/docs/source/03-05-product-guide-toxic-comments.md @@ -50,7 +50,7 @@ trying to improve a broken part of the internet. ## How do I add the Toxic Comments plugin? To enable this behavior, visit the -[talk-plugin-toxic-comments](./additional-plugins/#talk-plugin-toxic-comments) +[talk-plugin-toxic-comments](/talk/additional-plugins/#talk-plugin-toxic-comments) plugin documentation. diff --git a/docs/source/_data/plugins.yml b/docs/source/_data/plugins.yml index 1b8dcc1c5..4c3621fcb 100644 --- a/docs/source/_data/plugins.yml +++ b/docs/source/_data/plugins.yml @@ -90,8 +90,6 @@ - reaction - name: talk-plugin-rich-text description: Enables rich text plugins that save data as HTML. -- name: talk-plugin-rich-text-pell - description: Enables the pell rich text editor. - name: talk-plugin-slack-notifications description: Sends all comments as notifications to a slack channel - name: talk-plugin-sort-most-liked @@ -135,4 +133,4 @@ description: Enables the dropdown used to display sorting options on the embed stream. tags: - default - - sorting \ No newline at end of file + - sorting diff --git a/docs/source/plugins/overview.md b/docs/source/plugins/overview.md index 03bb8dcee..26230dc00 100644 --- a/docs/source/plugins/overview.md +++ b/docs/source/plugins/overview.md @@ -116,4 +116,4 @@ configuration and will ensure that the image is ready to use by building all assets inside the image as well. For more information on the onbuild image, refer to the -[Installation from Docker](./installation-from-docker/) documentation. +[Installation from Docker](/talk/installation-from-docker/) documentation. diff --git a/docs/themes/coral/layout/partial/sidebar.swig b/docs/themes/coral/layout/partial/sidebar.swig index d6d4fbab1..9d0a335d6 100644 --- a/docs/themes/coral/layout/partial/sidebar.swig +++ b/docs/themes/coral/layout/partial/sidebar.swig @@ -17,7 +17,7 @@ diff --git a/plugin-api/alpha/client/hocs/index.js b/plugin-api/alpha/client/hocs/index.js new file mode 100644 index 000000000..3c860c7f5 --- /dev/null +++ b/plugin-api/alpha/client/hocs/index.js @@ -0,0 +1 @@ +export { withSlotElements } from 'coral-framework/hocs'; 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-notifications-category-reply/index.js b/plugins/talk-plugin-notifications-category-reply/index.js index 0b6fe62a0..e214975ac 100644 --- a/plugins/talk-plugin-notifications-category-reply/index.js +++ b/plugins/talk-plugin-notifications-category-reply/index.js @@ -2,6 +2,12 @@ const { get } = require('lodash'); const path = require('path'); const handle = async (ctx, comment) => { + // Check to see if this reply is visible. + if (!comment.visible) { + ctx.log.info('comment was not visible, not sending notification'); + return; + } + // Check to see if this is a reply to an existing comment. const parentID = get(comment, 'parent_id', null); if (parentID === null) { @@ -90,12 +96,31 @@ const hydrate = async (ctx, category, context) => { return [headline, replier, permalink]; }; -const handler = { - handle, - category: 'reply', - event: 'commentAdded', - hydrate, - digestOrder: 30, +// commentAcceptedHandleAdapter will check to see if we need to send a +// notification for this comment if the comment has been recently approved but +// has not been approved before. +const commentAcceptedHandleAdapter = (ctx, comment) => { + // Don't send a notification for a non-visible comment. + if (!comment.visible) { + ctx.log.info('comment was not visible, not sending notification'); + return; + } + + // Don't send a notification if the comment was previously visible. + if ( + // TODO: (wyattjoh) this check is quite brittle, replace with a more concrete check. + comment.status_history + .slice(0, comment.status_history.length - 1) + .some(({ type }) => ['ACCEPTED', 'NONE'].includes(type)) + ) { + ctx.log.info( + 'comment was previously already visible, not sending another notification' + ); + return; + } + + // Delegate to the handle function. + return handle(ctx, comment); }; module.exports = { @@ -115,5 +140,20 @@ module.exports = { }, }, translations: path.join(__dirname, 'translations.yml'), - notifications: [handler], + notifications: [ + { + handle, + category: 'reply', + event: 'commentAdded', + hydrate, + digestOrder: 30, + }, + { + handle: commentAcceptedHandleAdapter, + category: 'reply', + event: 'commentAccepted', + hydrate, + digestOrder: 30, + }, + ], }; diff --git a/plugins/talk-plugin-notifications-category-staff/index.js b/plugins/talk-plugin-notifications-category-staff/index.js index a5d12c175..bf7da0776 100644 --- a/plugins/talk-plugin-notifications-category-staff/index.js +++ b/plugins/talk-plugin-notifications-category-staff/index.js @@ -2,6 +2,11 @@ const { get } = require('lodash'); const path = require('path'); const handle = async (ctx, comment) => { + if (!comment.visible) { + ctx.log.info('comment was not visible, not sending notification'); + return; + } + // Check to see if this is a reply to an existing comment. const parentID = get(comment, 'parent_id', null); if (parentID === null) { @@ -111,13 +116,31 @@ const hydrate = async (ctx, category, context) => { return [headline, replier, organizationName, permalink]; }; -const handler = { - handle, - category: 'staff', - event: 'commentAdded', - hydrate, - supersedesCategories: ['reply'], - digestOrder: 20, +// commentAcceptedHandleAdapter will check to see if we need to send a +// notification for this comment if the comment has been recently approved but +// has not been approved before. +const commentAcceptedHandleAdapter = (ctx, comment) => { + // Don't send a notification for a non-visible comment. + if (!comment.visible) { + ctx.log.info('comment was not visible, not sending notification'); + return; + } + + // Don't send a notification if the comment was previously visible. + if ( + // TODO: (wyattjoh) this check is quite brittle, replace with a more concrete check. + comment.status_history + .slice(0, comment.status_history.length - 1) + .some(({ type }) => ['ACCEPTED', 'NONE'].includes(type)) + ) { + ctx.log.info( + 'comment was previously already visible, not sending another notification' + ); + return; + } + + // Delegate to the handle function. + return handle(ctx, comment); }; module.exports = { @@ -137,5 +160,22 @@ module.exports = { }, }, translations: path.join(__dirname, 'translations.yml'), - notifications: [handler], + notifications: [ + { + handle, + category: 'staff', + event: 'commentAdded', + hydrate, + supersedesCategories: ['reply'], + digestOrder: 20, + }, + { + handle: commentAcceptedHandleAdapter, + category: 'staff', + event: 'commentAccepted', + hydrate, + supersedesCategories: ['reply'], + digestOrder: 20, + }, + ], }; diff --git a/plugins/talk-plugin-notifications/client/components/Settings.css b/plugins/talk-plugin-notifications/client/components/Settings.css index f9a65c88d..0aaaa8ab3 100644 --- a/plugins/talk-plugin-notifications/client/components/Settings.css +++ b/plugins/talk-plugin-notifications/client/components/Settings.css @@ -1,6 +1,6 @@ .root { margin-bottom: 20px; - width: 350px; + max-width: 350px; } .innerSettings { diff --git a/plugins/talk-plugin-notifications/server/messages.js b/plugins/talk-plugin-notifications/server/messages.js index 67c4b9d6b..0ba9ee70b 100644 --- a/plugins/talk-plugin-notifications/server/messages.js +++ b/plugins/talk-plugin-notifications/server/messages.js @@ -63,8 +63,6 @@ const sendNotificationsBatch = async (ctx, notifications) => { return; } - console.log(notifications); - return Promise.all( map( notifications, 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/components/Button.css b/plugins/talk-plugin-rich-text/client/components/Button.css deleted file mode 100644 index f9f1ba186..000000000 --- a/plugins/talk-plugin-rich-text/client/components/Button.css +++ /dev/null @@ -1,20 +0,0 @@ -.button > i { - vertical-align: middle; -} - -.button { - background-color: transparent; - padding: 3px; - border: none; - color: #4e4e4e; - margin-right: 3px; -} - -.button:hover{ - cursor: pointer; - border-radius: 3px; - background-color: #eae8e8; -} -.icon { - font-size: 20px; -} diff --git a/plugins/talk-plugin-rich-text/client/components/Button.js b/plugins/talk-plugin-rich-text/client/components/Button.js deleted file mode 100644 index 330e3d993..000000000 --- a/plugins/talk-plugin-rich-text/client/components/Button.js +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styles from './Button.css'; -import { Icon, BareButton } from 'plugin-api/beta/client/components/ui'; -import cn from 'classnames'; - -class Button extends React.Component { - render() { - const { className, icon, title, onClick } = this.props; - return ( - - - - ); - } -} - -Button.propTypes = { - icon: PropTypes.string.isRequired, - className: PropTypes.string, - title: PropTypes.string, - onClick: PropTypes.func, -}; - -export default Button; diff --git a/plugins/talk-plugin-rich-text/client/components/CommentContent.css b/plugins/talk-plugin-rich-text/client/components/CommentContent.css index c473ba310..4f81058f4 100644 --- a/plugins/talk-plugin-rich-text/client/components/CommentContent.css +++ b/plugins/talk-plugin-rich-text/client/components/CommentContent.css @@ -3,7 +3,6 @@ background-color: #F6F6F6; padding: 10px; margin: 20px 0px 20px 10px; - font-style: italic; border-radius: 2px; &::after { content: none; diff --git a/plugins/talk-plugin-rich-text/client/components/Editor.css b/plugins/talk-plugin-rich-text/client/components/Editor.css index eabc1ee94..2259d01a3 100644 --- a/plugins/talk-plugin-rich-text/client/components/Editor.css +++ b/plugins/talk-plugin-rich-text/client/components/Editor.css @@ -1,14 +1,5 @@ -.contentEditable { +.commentContent { composes: content from "./CommentContent.css"; - background: #fff; - border: solid 1px #bbb; - min-height: 120px; - box-sizing: border-box; - outline: 0; - overflow-y: auto; - width: 100%; - padding: 10px; - font-style: unset; } .placeholder { @@ -16,3 +7,7 @@ margin: 12px 0 0 12px; color: #bbb; } + +.icon { + font-size: 20px; +} diff --git a/plugins/talk-plugin-rich-text/client/components/Editor.js b/plugins/talk-plugin-rich-text/client/components/Editor.js index 5c1497f00..e09931d38 100644 --- a/plugins/talk-plugin-rich-text/client/components/Editor.js +++ b/plugins/talk-plugin-rich-text/client/components/Editor.js @@ -4,19 +4,19 @@ import styles from './Editor.css'; import cn from 'classnames'; import { PLUGIN_NAME } from '../constants'; import { htmlNormalizer } from '../utils'; -import ContentEditable from 'react-contenteditable'; -import Toolbar from './Toolbar'; -import Button from './Button'; -import bowser from 'bowser'; +import RTE from './rte/RTE'; +import { Icon } from 'plugin-api/beta/client/components/ui'; +import { Bold, Italic, Blockquote } from './rte/features'; +import { t } from 'plugin-api/beta/client/services'; class Editor extends React.Component { ref = null; handleRef = ref => (this.ref = ref); - handleChange = evt => { + handleChange = c => { this.props.onInputChange({ - body: this.ref.htmlEl.innerText, - richTextBody: evt.target.value, + body: c.text, + richTextBody: c.html, }); }; @@ -40,55 +40,19 @@ class Editor extends React.Component { } }); } + if (this.props.isReply) { + this.ref.focus(); + } } componentWillUnmount() { this.props.unregisterHook(this.normalizeHook); } - getCurrentTagName() { - const sel = window.getSelection(); - const range = sel.getRangeAt(0); - if (range.startContainer.nodeName !== '#text') { - return range.startContainer.nodeName; - } - return range.startContainer.parentNode.tagName; - } - - formatBold = () => { - document.execCommand('bold'); - this.ref.htmlEl.focus(); - }; - - formatItalic = () => { - document.execCommand('italic'); - this.ref.htmlEl.focus(); - }; - - formatBlockquote = () => { - const currentTag = this.getCurrentTagName(); - if (currentTag === 'BLOCKQUOTE') { - document.execCommand('outdent'); - } else { - if (bowser.msie) { - document.execCommand('indent'); - } else { - document.execCommand('formatBlock', false, 'blockquote'); - } - } - this.ref.htmlEl.focus(); - }; - - outdentOnEnter = e => { - if (e.key === 'Enter' && !e.shiftKey) { - setTimeout(() => { - document.execCommand('outdent'); - }); - } - }; - render() { - const inputId = `${this.props.id}-rte`; + const { id, placeholder, label, disabled } = this.props; + + const inputId = `${id}-rte`; return (
- -
); @@ -134,7 +110,6 @@ Editor.propTypes = { onInputChange: PropTypes.func, disabled: PropTypes.bool, comment: PropTypes.object, - classNames: PropTypes.object, registerHook: PropTypes.func, unregisterHook: PropTypes.func, isReply: PropTypes.bool, diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.css b/plugins/talk-plugin-rich-text/client/components/rte/RTE.css new file mode 100644 index 000000000..2bf1feaf5 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.css @@ -0,0 +1,29 @@ +.contentEditable { + background: #fff; + border: solid 1px #bbb; + min-height: 120px; + box-sizing: border-box; + outline: 0; + overflow-y: auto; + width: 100%; + padding: 10px; + font-style: unset; + margin-bottom: 3px; +} + +.placeholder { + position: absolute; + margin: 12px 0 0 12px; + color: #bbb; +} + +.toolbarDisabled { + background: #f8f8f8; + cursor: default; +} + +.contentEditableDisabled { + background: #fafafa; + color: #888; + cursor: default; +} diff --git a/plugins/talk-plugin-rich-text/client/components/rte/RTE.js b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js new file mode 100644 index 000000000..3726fb7c1 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/RTE.js @@ -0,0 +1,392 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './RTE.css'; +import cn from 'classnames'; +import ContentEditable from 'react-contenteditable'; +import Toolbar from './components/Toolbar'; +import { + insertNewLine, + insertText, + getSelectionRange, + replaceSelection, + cloneNodeAndRange, + replaceNodeChildren, + selectEndOfNode, + isSelectionInside, + traverse, +} from './lib/dom'; +import createAPI from './lib/api'; +import Undo from './lib/undo'; +import bowser from 'bowser'; +import throttle from 'lodash/throttle'; + +class RTE extends React.Component { + /// Ref to react-contenteditable + ref = null; + + // Our "plugins" api. + api = createAPI( + () => this.ref.htmlEl, + () => this.handleChange(), + () => this.undo.canUndo(), + () => this.undo.canRedo(), + () => this.handleUndo(), + () => this.handleRedo(), + () => this.focused + ); + + // Instance of undo stack. + undo = new Undo(); + + // Refs to the features. + featuresRef = {}; + + // Export this for parent components. + focus = () => this.ref.htmlEl.focus(); + + unmounted = false; + focused = false; + + // Should be called on every change to feed + // our Undo stack. We save the innerHTML and if available + // a copy of the contentEditable node and a copy of the range. + saveCheckpoint = throttle((html, node, range) => { + const args = [html]; + if (node && range) { + args.push(...cloneNodeAndRange(node, range)); + } + this.undo.save(...args); + }, 1000); + + constructor(props) { + super(props); + this.saveCheckpoint(props.value); + } + + // Returns a handler that fills our `featuresRef`. + createFeatureRefHandler(key) { + return ref => { + if (ref) { + this.featuresRef[key] = ref; + } else { + delete this.featuresRef[key]; + } + }; + } + + // Ref to react-contenteditable. + handleRef = ref => (this.ref = ref); + + forEachFeature(callback) { + Object.keys(this.featuresRef).map(k => { + const instance = this.featuresRef[k].getFeatureInstance + ? this.featuresRef[k].getFeatureInstance() + : this.featuresRef[k]; + callback(instance); + }); + } + + componentWillReceiveProps(props) { + // Clear undo stack if content was set to sth different. + if (props.value !== this.ref.htmlEl.innerHTML) { + this.undo.clear(); + this.saveCheckpoint(props.value); + if (isSelectionInside(this.ref.htmlEl)) { + setTimeout(() => !this.unmounted && selectEndOfNode(this.ref.htmlEl)); + } + } + } + + componentWillUnmount() { + // Cancel pending stuff. + this.saveCheckpoint.cancel(); + this.unmounted = true; + } + + handleChange = () => { + // TODO: don't rely on this hack. + // It removes all `style` attr that + // remaining execCommand still add. + traverse(this.ref.htmlEl, n => { + n.removeAttribute && n.removeAttribute('style'); + }); + + this.props.onChange({ + text: this.ref.htmlEl.innerText, + html: this.ref.htmlEl.innerHTML, + }); + this.ref.htmlEl.focus(); + this.saveCheckpoint( + this.ref.htmlEl.innerHTML, + this.ref.htmlEl, + getSelectionRange() + ); + }; + + handleSelectionChange = () => { + // Let features know selection has changeed, so they + // can update. + this.forEachFeature(b => { + b.onSelectionChange && b.onSelectionChange(); + }); + }; + + // Allow features to handle shortcuts. + handleShortcut = e => { + let handled = false; + this.forEachFeature(b => { + if (!handled) { + handled = !!(b.onShortcut && b.onShortcut(e)); + } + }); + return handled; + }; + + // Called when Enter was pressed without shift. + // Traverses from bottom to top and calling + // feature handlers and stops when one has handled this event. + handleSpecialEnter = () => { + let handled = false; + const sel = window.getSelection(); + const range = sel.getRangeAt(0); + let container = range.startContainer; + while (!handled && container && container !== this.ref.htmlEl) { + this.forEachFeature(b => { + if (!handled) { + handled = !!(b.onEnter && b.onEnter(container)); + } + }); + container = container.parentNode; + } + return handled; + }; + + handleCut = () => { + // IE has issues not firing the onChange event. + if (bowser.msie) { + setTimeout(() => !this.unmounted && this.handleChange()); + } + }; + + handleFocus = () => { + this.focused = true; + }; + + handleBlur = () => { + this.focused = false; + // Sometimes the onselect event doesn't fire on blur. + this.handleSelectionChange(); + }; + + // We intercept pasting, so that we + // force text/plain content. + handlePaste = e => { + // Get text representation of clipboard + // This works cross browser. + const text = ( + (e.originalEvent || e).clipboardData || window.clipboardData + ).getData('Text'); + + // IE does this funny thing to change the selection after the paste + // event, remember the range for now. + const range = getSelectionRange().cloneRange(); + + // Run outside of event loop to fix + // selection issues with IE. + setTimeout(() => { + // Manually delete range, cope with IE. + if (!range.collapsed) { + range.deleteContents(); + } + + // insert text manually + insertText(text); + this.handleChange(); + }); + + e.preventDefault(); + return false; + }; + + handleKeyDown = e => { + // IE has issues not firing the onChange event. + if (bowser.msie) { + setTimeout(() => !this.unmounted && this.handleChange()); + } + + // Undo Redo 'Z' + if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { + if (e.shiftKey) { + this.handleRedo(); + } else { + this.handleUndo(); + } + e.preventDefault(); + return false; + } + + if (e.metaKey || e.ctrlKey) { + if (this.handleShortcut(e)) { + e.preventDefault(); + return false; + } + } + + // Newlines Or Special Enter Behaviors. + if (e.key === 'Enter') { + if (!e.shiftKey && this.handleSpecialEnter()) { + this.handleChange(); + e.preventDefault(); + return false; + } + + insertNewLine(true); + + this.handleChange(); + e.preventDefault(); + return false; + } + }; + + restoreCheckpoint(html, node, range) { + if (node && range) { + // We need to clone it, otherwise we'll mutate + // that original one which can still be in the undo stack. + const [nodeCloned, rangeCloned] = cloneNodeAndRange(node, range); + + // Remember range values, as `rangeCloned` can changed during + // DOM manipulation. + const startOffset = rangeCloned.startOffset; + const endOffset = rangeCloned.startOffset; + + // Rewrite startContainer if it was pointing to `nodeCloned`. + const startContainer = + rangeCloned.startContainer === nodeCloned + ? this.ref.htmlEl + : rangeCloned.startContainer; + + // Rewrite endContainer if it was pointing to `nodeCloned`. + const endContainer = + rangeCloned.endContainer === nodeCloned + ? this.ref.htmlEl + : rangeCloned.endContainer; + + // Replace children with the ones from nodeCloned. + replaceNodeChildren(this.ref.htmlEl, nodeCloned); + + // Now setup the selection range. + const finalRange = document.createRange(); + finalRange.setStart(startContainer, startOffset); + finalRange.setEnd(endContainer, endOffset); + + // SELECT! + replaceSelection(finalRange); + } else { + this.ref.htmlEl.innerHTML = html; + selectEndOfNode(this.ref.htmlEl); + } + this.handleChange(); + } + + handleUndo() { + this.saveCheckpoint.flush(); + if (this.undo.canUndo()) { + const [html, node, range] = this.undo.undo(); + this.restoreCheckpoint(html, node, range); + } + } + + handleRedo() { + this.saveCheckpoint.flush(); + if (this.undo.canRedo()) { + const [html, node, range] = this.undo.redo(); + this.restoreCheckpoint(html, node, range); + } + } + + renderFeatures() { + return this.props.features.map(b => { + return React.cloneElement(b, { + disabled: this.props.disabled, + api: this.api, + ref: this.createFeatureRefHandler(b.key), + }); + }); + } + + getClassNames() { + const { disabled } = this.props; + return { + toolbar: cn(this.props.toolbarClassName, { + [this.props.toolbarClassNameDisabled]: disabled, + [styles.toolbarDisabled]: disabled, + }), + content: cn(styles.contentEditable, this.props.contentClassName, { + [this.props.contentClassNameDisabled]: disabled, + [styles.contentEditableDisabled]: disabled, + }), + root: cn(this.props.className, { + [this.props.classNameDisabled]: disabled, + }), + placeholder: cn(styles.placeholder, this.props.placeholderClassName, { + [this.props.placeholderClassNameDisabled]: disabled, + }), + }; + } + + render() { + const { value, placeholder, inputId, disabled } = this.props; + + const classNames = this.getClassNames(); + + return ( +
+ + {this.renderFeatures()} + + {!value &&
{placeholder}
} + +
+ ); + } +} + +RTE.defaultProps = { + features: [], +}; + +RTE.propTypes = { + features: PropTypes.array, + inputId: PropTypes.string, + input: PropTypes.object, + onChange: PropTypes.func, + disabled: PropTypes.bool, + className: PropTypes.string, + classNameDisabled: PropTypes.string, + contentClassName: PropTypes.string, + contentClassNameDisabled: PropTypes.string, + toolbarClassName: PropTypes.string, + toolbarClassNameDisabled: PropTypes.string, + placeholderClassName: PropTypes.string, + placeholderClassNameDisabled: PropTypes.string, + placeholder: PropTypes.string, + value: PropTypes.string, +}; + +export default RTE; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css new file mode 100644 index 000000000..9f30022a0 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.css @@ -0,0 +1,51 @@ +.buttonReset { + user-select: none; + outline: invert none medium; + border: none; + touch-action: manipulation; + padding: 0; + overflow: hidden; + + -webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important; + &::-moz-focus-inner: { + border: 0; + } +} + +.button > i { + vertical-align: middle; +} + +.button { + composes: buttonReset; + background-color: transparent; + padding: 3px; + border: none; + color: #4e4e4e; + margin-right: 3px; + border-radius: 3px; +} + +.button:hover { + cursor: pointer; + background-color: #eae8e8; +} + +.button.active { + background-color: #ddd; +} + +.button:disabled{ + color: #bbb; + cursor: default; + background: none; +} + +@media (-moz-touch-enabled: 1), (pointer:coarse) { + .button:hover{ + background-color: transparent; + } + .button.active { + background-color: #ddd; + } +} diff --git a/plugins/talk-plugin-rich-text/client/components/rte/components/Button.js b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.js new file mode 100644 index 000000000..f99648cb7 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/components/Button.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './Button.css'; +import cn from 'classnames'; + +class Button extends React.Component { + render() { + const { + className, + title, + onClick, + children, + active, + activeClassName, + disabled, + } = this.props; + return ( + + ); + } +} + +Button.propTypes = { + className: PropTypes.string, + activeClassName: PropTypes.string, + title: PropTypes.string, + onClick: PropTypes.func, + children: PropTypes.node, + active: PropTypes.bool, + disabled: PropTypes.bool, +}; + +export default Button; diff --git a/plugins/talk-plugin-rich-text/client/components/Toolbar.css b/plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.css similarity index 100% rename from plugins/talk-plugin-rich-text/client/components/Toolbar.css rename to plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.css diff --git a/plugins/talk-plugin-rich-text/client/components/Toolbar.js b/plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.js similarity index 100% rename from plugins/talk-plugin-rich-text/client/components/Toolbar.js rename to plugins/talk-plugin-rich-text/client/components/rte/components/Toolbar.js diff --git a/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js new file mode 100644 index 000000000..028757222 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/factories/createToggle.js @@ -0,0 +1,85 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from '../components/Button'; + +/** + * createToggle creates a button that can be active, inactive or disabled + * and reacts on clicks. All callbacks are bound to the API instance. + */ +const createToggle = ( + execCommand, + { onEnter, onShortcut, isActive = () => false, isDisabled = () => false } = {} +) => { + class Toggle extends React.Component { + state = { + active: false, + disabled: false, + }; + + execCommand = () => execCommand.apply(this.props.api); + isActive = () => isActive.apply(this.props.api); + isDisabled = () => isDisabled.apply(this.props.api); + onEnter = (...args) => onEnter && onEnter.apply(this.props.api, args); + onShortcut = (...args) => + onShortcut && onShortcut.apply(this.props.api, args); + unmounted = false; + + componentWillUnmount() { + this.unmounted = true; + } + + formatToggle = () => { + this.execCommand(); + }; + + handleClick = () => { + this.props.api.focus(); + this.formatToggle(); + this.props.api.focus(); + setTimeout(() => !this.unmounted && this.syncState()); + }; + + syncState = () => { + if (this.state.active !== this.isActive()) { + this.setState(state => ({ + active: !state.active, + })); + } + if (this.state.disabled !== this.isDisabled()) { + this.setState(state => ({ + disabled: !state.disabled, + })); + } + }; + + onSelectionChange() { + this.syncState(); + } + + render() { + const { className, title, children, disabled } = this.props; + return ( + + ); + } + } + + Toggle.propTypes = { + api: PropTypes.object, + className: PropTypes.string, + title: PropTypes.string, + children: PropTypes.node, + disabled: PropTypes.bool, + }; + return Toggle; +}; + +export default createToggle; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/features/Blockquote.js b/plugins/talk-plugin-rich-text/client/components/rte/features/Blockquote.js new file mode 100644 index 000000000..201260fcd --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/features/Blockquote.js @@ -0,0 +1,49 @@ +import createToggle from '../factories/createToggle'; +import { + findIntersecting, + insertNewLineAfterNode, + insertNodes, + getSelectedNodesExpanded, + outdentBlock, + selectEndOfNode, + indentNodes, +} from '../lib/dom'; + +function execCommand() { + const bq = findIntersecting('BLOCKQUOTE', this.container); + if (bq) { + outdentBlock(bq, true); + } else { + // Expanded selection means we always select whole lines. + const selectedNodes = getSelectedNodesExpanded(); + if (selectedNodes.length) { + indentNodes(selectedNodes, 'blockquote', true); + } else { + const node = document.createElement('blockquote'); + node.appendChild(document.createElement('br')); + insertNodes(node); + selectEndOfNode(node); + } + } + this.broadcastChange(); +} + +function isActive() { + return this.focused && !!findIntersecting('BLOCKQUOTE', this.container); +} + +function onEnter(node) { + if (node.tagName !== 'BLOCKQUOTE') { + return; + } + insertNewLineAfterNode(node, true); + return true; +} + +const Blockquote = createToggle(execCommand, { onEnter, isActive }); + +Blockquote.defaultProps = { + children: 'Blockquote', +}; + +export default Blockquote; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/features/Bold.js b/plugins/talk-plugin-rich-text/client/components/rte/features/Bold.js new file mode 100644 index 000000000..fe072e2b1 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/features/Bold.js @@ -0,0 +1,44 @@ +import createToggle from '../factories/createToggle'; +import { findIntersecting, findAncestor } from '../lib/dom'; + +const boldTags = ['B', 'STRONG']; + +function execCommand() { + return document.execCommand('bold'); +} + +function isActive() { + return this.focused && document.queryCommandState('bold'); +} +function isDisabled() { + if (!this.focused) { + return false; + } + + // Disable whenever the bold styling came from a different + // tag than those we control. + return !!findIntersecting( + n => + n.nodeName !== '#text' && + window.getComputedStyle(n).getPropertyValue('font-weight') === 'bold' && + !boldTags.includes(n.tagName) && + !findAncestor(n, n => boldTags.includes(n.tagName), this.container), + this.container + ); +} +function onShortcut(e) { + if (e.key === 'b') { + if (!isDisabled.apply(this)) { + execCommand.apply(this); + } + return true; + } +} + +const Bold = createToggle(execCommand, { isActive, isDisabled, onShortcut }); + +Bold.defaultProps = { + children: 'Bold', +}; + +export default Bold; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/features/Italic.js b/plugins/talk-plugin-rich-text/client/components/rte/features/Italic.js new file mode 100644 index 000000000..80703f47c --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/features/Italic.js @@ -0,0 +1,42 @@ +import createToggle from '../factories/createToggle'; +import { findIntersecting, findAncestor } from '../lib/dom'; + +const italicTags = ['I', 'EM']; + +function execCommand() { + return document.execCommand('italic'); +} +function isActive() { + return this.focused && document.queryCommandState('italic'); +} +function isDisabled() { + if (!this.focused) { + return false; + } + // Disable whenever the italic styling came from a different + // tag than those we control. + return !!findIntersecting( + n => + n.nodeName !== '#text' && + window.getComputedStyle(n).getPropertyValue('font-style') === 'italic' && + !italicTags.includes(n.tagName) && + !findAncestor(n, n => italicTags.includes(n.tagName), this.container), + this.container + ); +} +function onShortcut(e) { + if (e.key === 'i') { + if (!isDisabled.apply(this)) { + execCommand.apply(this); + } + return true; + } +} + +const Italic = createToggle(execCommand, { isActive, isDisabled, onShortcut }); + +Italic.defaultProps = { + children: 'Italic', +}; + +export default Italic; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/features/index.js b/plugins/talk-plugin-rich-text/client/components/rte/features/index.js new file mode 100644 index 000000000..5e985fe6a --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/features/index.js @@ -0,0 +1,3 @@ +export { default as Bold } from './Bold'; +export { default as Italic } from './Italic'; +export { default as Blockquote } from './Blockquote'; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js b/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js new file mode 100644 index 000000000..9e018f093 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/api.js @@ -0,0 +1,37 @@ +import { isSelectionInside } from './dom'; + +/** + * An instance of API is passed to all the buttons to + * interact with RTE, which servers as a clean abstraction. + */ +function createAPI( + getContainer, + broadcastChange, + canUndo, + canRedo, + undo, + redo, + getFocused +) { + return { + broadcastChange, + canUndo, + canRedo, + undo, + redo, + get focused() { + return getFocused(); + }, + get container() { + return getContainer(); + }, + focus() { + this.container.focus(); + }, + isSelectionInside() { + return isSelectionInside(getContainer()); + }, + }; +} + +export default createAPI; diff --git a/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js new file mode 100644 index 000000000..a5fbec19e --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/dom.js @@ -0,0 +1,681 @@ +/** + * Traverse DOM tree until callback returns anything. + */ +export function traverse(node, callback) { + let result; + for (let i = 0; i < node.childNodes.length; i++) { + const child = node.childNodes[i]; + result = callback(child); + if (result === undefined) { + result = traverse(child, callback); + } + if (result !== undefined) { + return result; + } + } +} + +/** + * Traverse DOM tree backwards until callback returns anything. + * If hits limitTo returns null. + */ +export function traverseUp(node, callback, limitTo) { + let result; + if (node.isSameNode(limitTo)) { + return null; + } + while (node.parentNode) { + node = node.parentNode; + result = callback(node); + if (result !== undefined) { + return result; + } + if (limitTo && node.isSameNode(limitTo)) { + return null; + } + } +} + +/** + * Find ancestor with given tag or whith callback returning true. + * If `limitTo` is passed, the search is limited to this container. + */ +export function findAncestor(node, tagOrCallback, limitTo) { + const callback = + typeof tagOrCallback === 'function' + ? tagOrCallback + : n => n.tagName === tagOrCallback; + return ( + traverseUp( + node, + n => { + if (callback(n)) { + return n; + } + }, + limitTo + ) || null + ); +} + +/** + * Find child with given tag or when callback return true. + */ +export function findChild(node, tagOrCallback) { + const callback = + typeof tagOrCallback === 'function' + ? tagOrCallback + : n => n.tagName === tagOrCallback; + return ( + traverse(node, n => { + if (callback(n)) { + return n; + } + }) || null + ); +} + +/** + * Find an node intersecting with the selection with given tag or + * with callback returning true. If `limitTo` is passed, the search + * is limited to this container. + */ +export function findIntersecting(tagOrCallback, limitTo) { + const callback = + typeof tagOrCallback === 'function' + ? tagOrCallback + : n => n.tagName === tagOrCallback; + + const range = getSelectionRange(); + if (!range) { + return null; + } + + if (callback(range.startContainer)) { + return range.startContainer; + } + + const ancestor = findAncestor(range.startContainer, callback, limitTo); + if (ancestor) { + return ancestor; + } + + const nodes = getSelectedChildren(range.commonAncestorContainer); + for (let i = 0; i < nodes.length; i++) { + if (callback(nodes[i])) { + return nodes[i]; + } + const found = findChild(nodes[i], callback); + if (found) { + return found; + } + } + return null; +} + +/** + * Same as node.contains but works in IE. + * In addition lookFor can also be a callback. + */ +export function nodeContains(node, lookFor) { + const callback = + typeof lookFor === 'function' ? lookFor : n => n.isSameNode(lookFor); + if (callback(node)) { + return true; + } + return !!findChild(node, callback); +} + +/** + * Returns true if node is not `inline` nor `inline-block`. + */ +export function isBlockElement(node) { + if (node.nodeName === '#text') { + return false; + } + return !window + .getComputedStyle(node) + .getPropertyValue('display') + .startsWith('inline'); +} + +/** + * Find parent that is a block element. + */ +export function findParentBlock(node) { + return findAncestor(node, isBlockElement); +} + +/** + * Find last parent before a block element. + */ +export function lastParentBeforeBlock(node) { + return findAncestor(node, n => !n.parentNode || isBlockElement(n.parentNode)); +} + +/** + * Like `Array.indexOf` but works on `childNodes`. + */ +export function indexOfChildNode(parent, child) { + for (let i = 0; i < parent.childNodes.length; i++) { + if (parent.childNodes[i] === child) { + return i; + } + } + return -1; +} + +/** + * Same as `document.execCommand('insertText', false, text)` but also + * works for IE. Changes Selection. + */ +export function insertText(text) { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + if (!range.collapsed) { + range.deleteContents(); + } + const newRange = document.createRange(); + const offset = range.startOffset; + const container = range.startContainer; + + if (container.nodeName === '#text') { + container.textContent = + container.textContent.slice(0, offset) + + text + + container.textContent.slice(offset); + const nextOffset = offset + text.length; + + newRange.setStart(container, nextOffset); + newRange.setEnd(container, nextOffset); + } else { + const textNode = document.createTextNode(text); + container.insertBefore(textNode, container.childNodes[offset]); + newRange.setStart(textNode, text.length); + newRange.setEnd(textNode, text.length); + } + replaceSelection(newRange); +} + +/** + * Insert nodes to current selection, + * does not change selection. + */ +export function insertNodes(...nodes) { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + if (!range.collapsed) { + range.deleteContents(); + } + const offset = range.startOffset; + const container = range.startContainer; + if (container.nodeName === '#text') { + const startSlice = container.textContent.slice(0, offset); + const endSlice = container.textContent.slice(offset); + if (startSlice) { + nodes.splice(0, 0, document.createTextNode(startSlice)); + } + if (endSlice) { + nodes.push(document.createTextNode(endSlice)); + } + const parentNode = container.parentNode; + nodes.forEach(n => parentNode.insertBefore(n, container)); + parentNode.removeChild(container); + } else { + let parentNode = container; + let nextSibling = container.childNodes[offset]; + nodes.forEach(n => parentNode.insertBefore(n, nextSibling)); + } +} + +/** + * Helper to replace current selection with range. + */ +export function replaceSelection(range) { + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +/** + * Helper to to know if selection is collapsed. + */ +export function isSelectionCollapsed() { + return window.getSelection().isCollapsed; +} + +/** + * Helper to get current selection range. + */ +export function getSelectionRange() { + const selection = window.getSelection(); + return selection.rangeCount ? selection.getRangeAt(0) : null; +} + +// Adds a bogus 'br' at the end of the node if not existant. +export function addBogusBR(node) { + if (!isBlockElement(node)) { + return; + } + if (!node.lastChild || !isBogusBR(node.lastChild)) { + node.appendChild(document.createElement('br')); + } +} + +/** + * Returns true if selection is completely inside + * given nodes. + */ +export function isSelectionInside(...nodes) { + let foundStart = false; + const range = getSelectionRange(); + if (!range) { + return false; + } + for (let i = 0; i < nodes.length; i++) { + if (!foundStart) { + foundStart = nodeContains(nodes[i], range.startContainer); + } + if (foundStart) { + const foundEnd = nodeContains(nodes[i], range.endContainer); + if (foundEnd) { + return true; + } + } + } + return false; +} + +/** + * Insert new line. This is what happens + * when adding new lines through pressing Enter. + * Deals with browers quirks. + */ +export function insertNewLine(changeSelection) { + // Insert
node. + const el = document.createElement('br'); + insertNodes(el); + + // If we are adding to the end of the node, we also need + // to add a bogus br. + if (!el.nextSibling) { + el.parentNode.appendChild(document.createElement('br')); + } + + // Adding directly before a block element needs also a bogus br. + if (el.nextSibling && isBlockElement(el.nextSibling)) { + el.parentNode.insertBefore(document.createElement('br'), el.nextSibling); + } + + // Calculate next selection. + const range = document.createRange(); + if (el.nextSibling) { + const offset = indexOfChildNode(el.parentNode, el.nextSibling); + range.setStart(el.parentNode, offset); + range.setEnd(el.parentNode, offset); + } else { + const offset = el.parentNode.childNodes.length - 1; + range.setStart(el.parentNode, offset); + range.setEnd(el.parentNode, offset); + } + + if (changeSelection) { + replaceSelection(range); + } +} + +/** + * Inserts a new line after given node. + */ +export function insertNewLineAfterNode(node, changeSelection) { + const el = document.createElement('br'); + if (node.nextSibling) { + node.parentNode.insertBefore(el, node.nextSibling); + } else { + node.parentNode.appendChild(el); + } + + if (changeSelection) { + const offset = indexOfChildNode(node.parentNode, el); + const range = document.createRange(); + range.setStart(node.parentNode, offset); + range.setEnd(node.parentNode, offset); + replaceSelection(range); + } +} + +/** + * Given a container and a offset, return the selected + * node. Usually to resolve the start or end of a range. + */ +export function getRangeNode(container, offset) { + if (container.nodeName === '#text') { + return container; + } + return container.childNodes[offset]; +} + +/** + * Returns an array of all nodes before `node`. + */ +export function getLeftOfNode(node) { + let result = []; + let leftMost = node; + while ( + leftMost.previousSibling && + leftMost.previousSibling.tagName !== 'BR' && + !isBlockElement(leftMost.previousSibling) + ) { + result.splice(0, 0, leftMost.previousSibling); + leftMost = leftMost.previousSibling; + } + return result; +} + +export function isBogusBR(node) { + return ( + (!node.previousSibling || !isBlockElement(node.previousSibling)) && + node.tagName === 'BR' && + (!node.nextSibling || isBlockElement(node.previousSibling)) + ); +} + +/** + * Returns an array of all nodes after `node`. + */ +export function getRightOfNode(node) { + let result = []; + let cur = node; + while ( + cur.nextSibling && + cur.nextSibling.tagName !== 'BR' && + !isBlockElement(cur.nextSibling) + ) { + cur = cur.nextSibling; + result.push(cur); + } + if ( + cur.nextSibling && + cur.nextSibling.tagName === 'BR' && + !isBogusBR(cur.nextSibling) + ) { + result.push(cur.nextSibling); + } + return result; +} + +/** + * Given `node` find the line it belongs too + * and return the whole line as an array. + */ +export function getWholeLine(node) { + if (isBlockElement(node)) { + return [node]; + } + const child = isBlockElement(node.parentNode) + ? node + : lastParentBeforeBlock(node); + if (child.tagName === 'BR') { + return [...getLeftOfNode(child), child]; + } + return [...getLeftOfNode(child), child, ...getRightOfNode(child)]; +} + +/** + * Get selected line at the start of the selection. + * Returns an array of nodes. + */ +export function getSelectedLine() { + const range = getSelectionRange(); + if (!range) { + return []; + } + const start = getRangeNode(range.startContainer, range.startOffset); + return start ? getWholeLine(start) : []; +} + +/** + * Finds a commen block ancestor in the selection + * and return "whole" lines as an array of nodes. + */ +export function getSelectedNodesExpanded() { + const range = getSelectionRange(); + if (!range) { + return []; + } + + if (range.collapsed) { + return getSelectedLine(); + } + + let ancestor = range.commonAncestorContainer; + if (!isBlockElement(ancestor)) { + ancestor = findParentBlock(ancestor); + } + + const result = getSelectedChildren(ancestor); + return [ + ...getLeftOfNode(result[0]), + ...result, + ...getRightOfNode(result[result.length - 1]), + ]; +} + +/** + * Returns array of children that intersects with + * the selection. + */ +export function getSelectedChildren(ancestor) { + const result = []; + const range = getSelectionRange(); + if (!range) { + return result; + } + if (!range) { + return result; + } + + const start = getRangeNode(range.startContainer, range.startOffset); + const end = getRangeNode(range.endContainer, range.endOffset); + let foundStart = false; + for (let i = 0; i < ancestor.childNodes.length; i++) { + const node = ancestor.childNodes[i]; + if (!foundStart) { + if (nodeContains(node, start)) { + foundStart = true; + } + } + if (foundStart) { + result.push(node); + if (nodeContains(node, end)) { + break; + } + } + } + return result; +} + +/** + * Removes node and assimilate its children with the parent. + */ +export function outdentBlock(node, changeSelection) { + // Save previous range. + const selectionWasInside = isSelectionInside(node); + const { + startContainer, + startOffset, + endContainer, + endOffset, + } = getSelectionRange(); + + // Remove bogus br + if (node.lastChild && node.lastChild.tagName === 'BR') { + node.removeChild(node.lastChild); + } + + // A new lines to substitute the missing block element. + const needLineAfter = + node.nextSibling && + !isBlockElement(node.nextSibling) && + node.lastChild && + !isBlockElement(node.lastChild); + const needLineBefore = + node.previousSibling && + !isBlockElement(node.previousSibling) && + node.previousSibling.tageName !== 'BR'; + + const parentNode = node.parentNode; + + if (needLineBefore) { + parentNode.insertBefore(document.createElement('BR'), node); + } + + const previousOffset = indexOfChildNode(parentNode, node); + + while (node.firstChild) { + parentNode.insertBefore(node.firstChild, node); + } + + if (needLineAfter) { + parentNode.insertBefore(document.createElement('BR'), node); + } + + parentNode.removeChild(node); + + if (changeSelection) { + const range = document.createRange(); + + if (selectionWasInside) { + if (startContainer === node) { + range.setStart(parentNode, startOffset + previousOffset); + } else { + range.setStart(startContainer, startOffset); + } + if (endContainer === node) { + range.setEnd(parentNode, endOffset + previousOffset); + } else { + range.setEnd(endContainer, endOffset); + } + } else { + range.setStart(parentNode, previousOffset); + range.setEnd(parentNode, previousOffset); + } + replaceSelection(range); + } +} + +/** + * Indent children. + */ +export function indentNodes(nodes, tagName, changeSelection) { + const parentNode = nodes[0].parentNode; + const node = document.createElement(tagName); + + // Remove bogus BR if the blockquote is the last element. + // Otherwise there will be an unwanted empty line. + const lastNode = nodes[nodes.length - 1]; + if ( + lastNode.nextSibling === parentNode.lastChild && + isBogusBR(parentNode.lastChild) + ) { + parentNode.removeChild(parentNode.lastChild); + } + + const firstNode = nodes[0]; + // Remove previous br as it is not needed + if (firstNode.previousSibling && firstNode.previousSibling.tagName === 'BR') { + parentNode.removeChild(firstNode.previousSibling); + } + + // Finally indent. + parentNode.insertBefore(node, firstNode); + nodes.forEach(n => { + node.appendChild(n); + }); + + if (changeSelection) { + selectEndOfNode(node); + } + + return node; +} + +function cloneNodeAndRangeHelper(node, range, rangeCloned) { + const nodeCloned = node.cloneNode(false); + for (let i = 0; i < node.childNodes.length; i++) { + const n = node.childNodes[i]; + nodeCloned.appendChild(cloneNodeAndRangeHelper(n, range, rangeCloned)); + } + if (range.startContainer === node) { + rangeCloned.setStart(nodeCloned, range.startOffset); + } + if (range.endContainer === node) { + rangeCloned.setEnd(nodeCloned, range.endOffset); + } + return nodeCloned; +} + +/** + * Clones node and returns both the cloned node and an equivalent Range. + */ +export function cloneNodeAndRange(node, range) { + const rangeCloned = range.cloneRange(); + const nodeCloned = cloneNodeAndRangeHelper(node, range, rangeCloned); + if ( + rangeCloned.startContainer === range.startContainer || + rangeCloned.endContainer === range.endContainer + ) { + throw new Error('Range not inside node'); + } + return [nodeCloned, rangeCloned]; +} + +/** + * Take children of the second node and replace children of first node. + */ +export function replaceNodeChildren(node, node2) { + while (node.firstChild) { + node.removeChild(node.firstChild); + } + while (node2.firstChild) { + node.appendChild(node2.firstChild); + } +} + +/** + * Tries to select the end of node. + * Currently looks for
and text nodes to find a suitable + * candidate for a selection. + */ +export function selectEndOfNode(node) { + for (let i = node.childNodes.length - 1; i >= 0; i--) { + let child = node.childNodes[i]; + const s = selectEndOfNode(child); + if (s) { + return true; + } + if (child.tagName === 'BR') { + if ( + child.previousSibling && + child.previousSibling.childName === '#text' + ) { + child = child.previousSibling; + } else { + const offset = indexOfChildNode(node, child); + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset); + replaceSelection(range); + return true; + } + } + if (child.nodeName === '#text') { + const range = document.createRange(); + range.setStart(child, child.textContent.length); + range.setEnd(child, child.textContent.length); + replaceSelection(range); + return true; + } + } + return false; +} diff --git a/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js b/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js new file mode 100644 index 000000000..eef076567 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/components/rte/lib/undo.js @@ -0,0 +1,65 @@ +/** + * Simple size limited Undo stack. + */ +export default class Undo { + /** + * undoStack contains all known values. + * The last value of this stack represent + * the most recent change. + */ + undoStack = []; + redoStack = []; + size; + + constructor(size = 100) { + this.size = size; + } + + clear() { + this.undoStack = []; + this.redoStack = []; + } + + canUndo() { + return this.undoStack.length > 1; + } + + canRedo() { + return this.redoStack.length; + } + + undo() { + if (!this.canUndo()) { + throw new Error('Nothing to undo'); + } + const cur = this.undoStack.pop(); + this.redoStack.push(cur); + return this.undoStack[this.undoStack.length - 1]; + } + + redo() { + if (!this.canRedo()) { + throw new Error('Nothing to redo'); + } + const x = this.redoStack.pop(); + this.undoStack.push(x); + return x; + } + + save(x, ...meta) { + // Ignore if we already have that saved. + if ( + this.undoStack.length && + this.undoStack[this.undoStack.length - 1][0] === x + ) { + return; + } + this.undoStack.push([x, ...meta]); + + // Adhere to maximum size. + if (this.undoStack.length > this.size) { + this.undoStack.splice(0, 1); + } + this.redoStack = []; + } +} 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..2f1e9ac0d 100644 --- a/plugins/talk-plugin-rich-text/client/index.js +++ b/plugins/talk-plugin-rich-text/client/index.js @@ -1,13 +1,17 @@ import Editor from './containers/Editor'; import CommentContent from './containers/CommentContent'; +import AdminCommentContent from './containers/AdminCommentContent'; +import translations from './translations.yml'; + import { gql } from 'react-apollo'; export default { + translations, slots: { draftArea: [Editor], commentContent: [CommentContent], - adminCommentContent: [CommentContent], - userDetailCommentContent: [CommentContent], + adminCommentContent: [AdminCommentContent], + userDetailCommentContent: [AdminCommentContent], }, fragments: { CreateCommentResponse: gql` diff --git a/plugins/talk-plugin-rich-text/client/translations.yml b/plugins/talk-plugin-rich-text/client/translations.yml new file mode 100644 index 000000000..0f230ace5 --- /dev/null +++ b/plugins/talk-plugin-rich-text/client/translations.yml @@ -0,0 +1,6 @@ +en: + talk-plugin-rich-text: + format_bold: bold + format_italic: italic + format_blockquote: blockquote + diff --git a/plugins/talk-plugin-rich-text/client/utils.js b/plugins/talk-plugin-rich-text/client/utils.js index 4f49b3654..80132d646 100644 --- a/plugins/talk-plugin-rich-text/client/utils.js +++ b/plugins/talk-plugin-rich-text/client/utils.js @@ -1,12 +1,7 @@ export function htmlNormalizer(htmlInput) { let str = htmlInput; - // We are normalizing the input from contenteditable of each browser, also removing unnecesary html tags - // https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content#Differences_in_markup_generation - - // Old browsers uses `p` normalize to `div` instead. - str = str - .replace(/

/g, '

') // IE and old browsers outputs

instead of

s - .replace(/<\/p>/g, '
'); // IE and old browsers outputs

instead of

s + // Some tags have not been normalized across browsers in `Coral RTE` yet. + // So we'll do this manual step here for now. // Harmonize all to tag. str = str diff --git a/plugins/talk-plugin-rich-text/server/config.js b/plugins/talk-plugin-rich-text/server/config.js index 347502d08..a9614a8f0 100644 --- a/plugins/talk-plugin-rich-text/server/config.js +++ b/plugins/talk-plugin-rich-text/server/config.js @@ -14,7 +14,7 @@ const config = { // TODO: move to admin eventually // Super strict rules to make sure users only submit the tags they are allowed dompurify: { - ALLOWED_TAGS: ['b', 'i', 'blockquote', 'br', 'div'], + ALLOWED_TAGS: ['b', 'i', 'blockquote', 'br', 'div', 'span'], ALLOWED_ATTR: [], }, diff --git a/plugins/talk-plugin-toxic-comments/README.md b/plugins/talk-plugin-toxic-comments/README.md index efd5f550a..60c05ad9f 100644 --- a/plugins/talk-plugin-toxic-comments/README.md +++ b/plugins/talk-plugin-toxic-comments/README.md @@ -12,7 +12,7 @@ plugin: Using the [Perspective API](http://perspectiveapi.com/), this plugin will warn users and reject comments that exceed the predefined toxicity threshold. For more information on what Toxic Comments are, check out the -[Toxic Comments](./toxic-comments/) documentation. +[Toxic Comments](/talk/toxic-comments/) documentation. Configuration: