[CORL-1029] Better HTML Support (#2956)

* feat: Improve html handling, integrate new @coralproject/rte

* chore: refactor and add comments

* chore: remove obsolete line

* chore: rename `inputId` to `inputID`

* chore: upgrade @coralproject/rte

* fix: update snapshots

* chore: apply review suggestions

* fix: snapshot / tests

* fix: merge issues

* [CORL-1056] Configurable RTE (#2967)

* fix: merge issues

* feat: Configure RTE

* test: add tests

* chore: just a comment

* chore: remove unused translations
This commit is contained in:
Vinh
2020-06-08 13:24:38 -06:00
committed by GitHub
parent ba08447d5e
commit 72e1b249b1
94 changed files with 6055 additions and 1795 deletions
+3807 -987
View File
File diff suppressed because it is too large Load Diff
+11 -6
View File
@@ -149,7 +149,7 @@
"@babel/preset-typescript": "^7.9.0",
"@babel/runtime-corejs3": "^7.9.2",
"@coralproject/npm-run-all": "^4.1.5",
"@coralproject/rte": "^0.11.1",
"@coralproject/rte": "^1.0.0",
"@fluent/react": "^0.11.1",
"@intervolga/optimize-cssnano-plugin": "^1.0.6",
"@types/archiver": "^3.1.0",
@@ -185,8 +185,8 @@
"@types/html-to-text": "^1.4.31",
"@types/html-webpack-plugin": "^3.2.2",
"@types/ioredis": "^4.14.9",
"@types/jest": "^25.1.4",
"@types/jest-axe": "^3.2.1",
"@types/jest": "^25.2.1",
"@types/jest-axe": "^3.2.2",
"@types/jsdom": "^16.2.0",
"@types/jsonwebtoken": "^8.3.8",
"@types/linkifyjs": "^2.1.3",
@@ -292,10 +292,10 @@
"html-webpack-plugin": "^4.0.4",
"husky": "^4.2.3",
"intersection-observer": "^0.7.0",
"jest": "^25.2.4",
"jest": "^26.0.1",
"jest-axe": "^3.4.0",
"jest-junit": "^10.0.0",
"jest-localstorage-mock": "^2.4.0",
"jest-localstorage-mock": "^2.4.2",
"jest-mock-console": "^1.0.0",
"keymaster": "^1.6.2",
"lint-staged": "^10.1.1",
@@ -356,7 +356,7 @@
"terser-webpack-plugin": "^2.3.5",
"thread-loader": "^2.1.3",
"timekeeper": "^2.2.0",
"ts-jest": "^25.3.0",
"ts-jest": "25.4.0",
"ts-loader": "^6.2.2",
"ts-node": "^8.8.1",
"ts-node-dev": "^1.0.0-pre.44",
@@ -379,6 +379,11 @@
"whatwg-fetch": "^3.0.0"
},
"dependencies-pins-documentation": {
"ts-jest@25.4.0": [
"Newer versions require every definition file to be included",
"in tsconfig.json in the `files` field. This is cumbersome and",
"here is a discussion of it: https://github.com/kulshekhar/ts-jest/issues/1604"
],
"wait-for-expect@1.x.x": [
"Newer versions breaks the use of jest fake timers"
],
+1
View File
@@ -77,6 +77,7 @@ export type Config = typeof config;
export const createClientEnv = (c: Config) => ({
NODE_ENV: c.get("env"),
WEBPACK: "true",
});
// Setup the base configuration.
@@ -1,20 +1,50 @@
import cn from "classnames";
import React, { FunctionComponent, useMemo } from "react";
import striptags from "striptags";
import {
getPhrasesRegExp,
GetPhrasesRegExpOptions,
markHTMLNode,
} from "coral-admin/helpers";
import { createPurify } from "coral-common/utils/purify";
import {
ALL_FEATURES,
createSanitize,
Sanitize,
} from "coral-common/helpers/sanitize";
import styles from "./CommentContent.css";
/**
* Create a purify instance that will be used to handle HTML content.
* Return a purify instance that will be used to handle HTML content.
*/
const purify = createPurify(window, false);
const getSanitize: (highlight: boolean) => Sanitize = (() => {
let sanitizers: Record<"default" | "highlight", Sanitize> | null = null;
return (highlight: boolean) => {
if (!sanitizers) {
sanitizers = {
default: createSanitize(window, {
// Allow all RTE features to be displayed.
features: ALL_FEATURES,
}),
highlight: createSanitize(window, {
// We need normalized text nodes to mark nodes for suspect/banned words.
normalize: true,
// Allow all RTE features to be displayed.
features: ALL_FEATURES,
config: {
FORBID_TAGS: highlight
? ["b", "strong", "i", "em", "s", "span"]
: [],
},
}),
};
}
if (highlight) {
return sanitizers.highlight!;
}
return sanitizers.default!;
};
})();
interface Props {
className?: string;
@@ -51,15 +81,7 @@ const CommentContent: FunctionComponent<Props> = ({
}
// Sanitize the input for display.
let html = purify.sanitize(children);
if (highlight) {
html = striptags(html, ["a"]);
}
// We create a Shadow DOM Tree with the HTML body content and use it as a
// parser.
const node = document.createElement("div");
node.innerHTML = html;
const node = getSanitize(highlight)(children);
// If the expression is available, then mark the nodes.
if (expression) {
@@ -110,7 +110,7 @@ const CommentLengthConfig: FunctionComponent<Props> = ({ disabled }) => (
<TextFieldAdornment>Characters</TextFieldAdornment>
</Localized>
}
placeholder={"No limit"}
placeholder="No limit"
textAlignCenter
/>
</Localized>
@@ -18,6 +18,7 @@ import CommentLengthConfig from "./CommentLengthConfig";
import GuidelinesConfig from "./GuidelinesConfig";
import LocaleConfig from "./LocaleConfig";
import ReactionConfigContainer from "./ReactionConfigContainer";
import RTEConfig from "./RTEConfig";
import SitewideCommentingConfig from "./SitewideCommentingConfig";
import StaffConfig from "./StaffConfig";
@@ -44,6 +45,7 @@ const GeneralConfigContainer: React.FunctionComponent<Props> = ({
<SitewideCommentingConfig disabled={submitting} />
<AnnouncementConfigContainer disabled={submitting} settings={settings} />
<GuidelinesConfig disabled={submitting} />
<RTEConfig disabled={submitting} />
<CommentLengthConfig disabled={submitting} />
<CommentEditingConfig disabled={submitting} />
<ClosingCommentStreamsConfig disabled={submitting} />
@@ -67,6 +69,7 @@ const enhanced = withFragmentContainer<Props>({
...SitewideCommentingConfig_formValues @relay(mask: false)
...ReactionConfig_formValues @relay(mask: false)
...StaffConfig_formValues @relay(mask: false)
...RTEConfig_formValues @relay(mask: false)
...ReactionConfigContainer_settings
}
@@ -0,0 +1,10 @@
.disabledLabel {
color: var(--v2-colors-grey-400);
}
.spoilerDesc {
font-size: var(--v2-font-size-2);
font-weight: var(--v2-font-weight-primary-regular);
font-family: var(--v2-font-family-primary);
color: var(--v2-colors-grey-400);
padding-left: 26px;
}
@@ -0,0 +1,125 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent } from "react";
import { Field, FormSpy } from "react-final-form";
import { graphql } from "react-relay";
import { parseBool } from "coral-framework/lib/form";
import {
CheckBox,
FieldSet,
FormField,
FormFieldDescription,
Label,
} from "coral-ui/components/v2";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import OnOffField from "../../OnOffField";
import styles from "./RTEConfig.css";
// eslint-disable-next-line no-unused-expressions
graphql`
fragment RTEConfig_formValues on Settings {
rte {
enabled
strikethrough
spoiler
}
}
`;
interface Props {
disabled: boolean;
}
const RTEConfig: FunctionComponent<Props> = ({ disabled }) => (
<ConfigBox
title={
<Localized id="configure-general-rte-title">
<Header container={<legend />}>Rich-text comments</Header>
</Localized>
}
container={<FieldSet />}
>
<Localized id="configure-general-rte-express">
<FormFieldDescription>
Give your community more ways to express themselves beyond plain text
with rich-text formatting.
</FormFieldDescription>
</Localized>
<FormField container={<fieldset />}>
<Localized id="configure-general-rte-richTextComments">
<Label>Rich-Text comments</Label>
</Localized>
<OnOffField
name="rte.enabled"
disabled={disabled}
onLabel={
<Localized id="configure-general-rte-onBasicFeatures">
<span>On - bold, italics, block quotes, and bulletted lists</span>
</Localized>
}
/>
</FormField>
<FormSpy subscription={{ values: true }}>
{(props) => {
const rteDisabled = !props.values.rte.enabled;
return (
<>
<FormField container={<fieldset />}>
<Localized id="configure-general-rte-additional">
<Label
className={cn({
[styles.disabledLabel]: rteDisabled,
})}
>
Additional rich-text options
</Label>
</Localized>
<Field name="rte.strikethrough" type="checkbox" parse={parseBool}>
{({ input }) => (
<Localized id="configure-general-rte-strikethrough">
<CheckBox
{...input}
id={input.name}
disabled={rteDisabled || disabled}
>
Strikethrough
</CheckBox>
</Localized>
)}
</Field>
<Field name="rte.spoiler" type="checkbox" parse={parseBool}>
{({ input }) => (
<div>
<Localized id="configure-general-rte-spoiler">
<CheckBox
{...input}
id={input.name}
disabled={rteDisabled || disabled}
>
Spoiler
</CheckBox>
</Localized>
<Localized id="configure-general-rte-spoilerDesc">
<div className={styles.spoilerDesc}>
Words and phrases formatted as Spoiler are hidden behind
a dark background until the reader chooses to reveal the
text.
</div>
</Localized>
</div>
)}
</Field>
</FormField>
</>
);
}}
</FormSpy>
</ConfigBox>
);
export default RTEConfig;
@@ -117,7 +117,7 @@ const SlackConfigContainer: FunctionComponent<Props> = ({ form, settings }) => {
<FieldArray name="slack.channels">
{({ fields }) =>
fields
.map((channel, index) => (
.map((channel: any, index: number) => (
<SlackChannel
key={index}
channel={channel}
@@ -551,6 +551,163 @@ here:
</fieldset>
</div>
</fieldset>
<fieldset
className="FieldSet-root Box-root ConfigBox-root"
>
<div
className="Box-root Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
>
<div>
<legend
className="Header-root"
>
Rich-text comments
</legend>
</div>
<div />
</div>
<div
className="ConfigBox-content"
>
<fieldset
className="FieldSet-root Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<p
className="FormFieldDescription-root"
>
Give your community more ways to express themselves beyond plain text with rich-text formatting.
</p>
<fieldset
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
>
<label
className="Label-root"
>
Rich-text comments
</label>
<div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={true}
className="RadioButton-input"
disabled={false}
id="rte.enabled-true"
name="rte.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="true"
/>
<label
className="RadioButton-label RadioButton-labelChecked"
htmlFor="rte.enabled-true"
>
<span>
On - bold, italics, block quotes, and bulletted lists
</span>
</label>
</div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={false}
className="RadioButton-input"
disabled={false}
id="rte.enabled-false"
name="rte.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="false"
/>
<label
className="RadioButton-label"
htmlFor="rte.enabled-false"
>
<span>
Off
</span>
</label>
</div>
</div>
</fieldset>
<fieldset
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
>
<label
className="Label-root"
>
Additional rich-text options
</label>
<div
className="CheckBox-root"
>
<input
checked={false}
className="CheckBox-input"
disabled={false}
id="rte.strikethrough"
name="rte.strikethrough"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="checkbox"
value={false}
/>
<label
className="CheckBox-label"
htmlFor="rte.strikethrough"
>
<span
className="CheckBox-labelSpan"
>
Strikethrough
</span>
</label>
</div>
<div>
<div
className="CheckBox-root"
>
<input
checked={false}
className="CheckBox-input"
disabled={false}
id="rte.spoiler"
name="rte.spoiler"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="checkbox"
value={false}
/>
<label
className="CheckBox-label"
htmlFor="rte.spoiler"
>
<span
className="CheckBox-labelSpan"
>
Spoiler
</span>
</label>
</div>
<div
className="RTEConfig-spoilerDesc"
>
Words and phrases formatted as Spoiler are hidden behind a
dark background until the reader chooses to reveal the text.
</div>
</div>
</fieldset>
</fieldset>
</div>
</fieldset>
<fieldset
className="FieldSet-root Box-root ConfigBox-root"
>
@@ -148,7 +148,7 @@ it("prevents admin lock out", async () => {
// Let's disable local auth.
act(() => {
within(container).getByLabelText("Enabled").props.onChange();
within(container).getByLabelText("Enabled").props.onChange(false);
});
// Send form
@@ -203,7 +203,8 @@ it("prevents stream lock out", async () => {
try {
window.confirm = stubCancel;
// Let's disable stream target in local auth.
act(() => streamTarget.props.onChange());
act(() => streamTarget.props.onChange(false));
within(streamTarget).debug();
// Send form
await act(async () => await form.props.onSubmit());
@@ -215,9 +216,6 @@ it("prevents stream lock out", async () => {
});
window.confirm = stubContinue;
// Let's enable stream target in local auth.
act(() => streamTarget.props.onChange());
// Send form
await act(async () => await form.props.onSubmit());
@@ -538,3 +538,66 @@ it("handle server error", async () => {
);
});
});
it("change rte config", async () => {
const resolvers = createResolversStub<GQLResolver>({
Mutation: {
updateSettings: ({ variables }) => {
expectAndFail(variables.settings.rte).toEqual({
enabled: true,
strikethrough: true,
spoiler: false,
});
return {
settings: pureMerge(settings, variables.settings),
};
},
},
});
const {
configureContainer,
generalContainer,
saveChangesButton,
} = await createTestRenderer({
resolvers,
});
const rteContainer = within(
generalContainer
).getAllByText("Rich-text comments", { selector: "fieldset" })[0];
const onField = within(rteContainer).getByLabelText("On", { exact: false });
const offField = within(rteContainer).getByLabelText("Off", { exact: false });
const strikethroughField = within(rteContainer).getByLabelText(
"Strikethrough"
);
// Turn off rte will disable additional options.
act(() => offField.props.onChange(offField.props.value.toString()));
expect(strikethroughField.props.disabled).toBe(true);
// Turn on rte will enable additional options.
act(() => onField.props.onChange(onField.props.value.toString()));
expect(strikethroughField.props.disabled).toBe(false);
// Enable strikethrough option.
act(() => strikethroughField.props.onChange(true));
// Send form
act(() => {
within(configureContainer).getByType("form").props.onSubmit();
});
// Submit button and text field should be disabled.
expect(saveChangesButton.props.disabled).toBe(true);
expect(onField.props.disabled).toBe(true);
expect(strikethroughField.props.disabled).toBe(true);
// Wait for submission to be finished
await act(async () => {
await wait(() => {
expect(onField.props.disabled).toBe(false);
expect(strikethroughField.props.disabled).toBe(false);
});
});
expect(resolvers.Mutation!.updateSettings!.called).toBe(true);
});
+5
View File
@@ -188,6 +188,11 @@ export const settings = createFixture<GQLSettings>({
},
multisite: false,
featureFlags: [],
rte: {
enabled: true,
strikethrough: false,
spoiler: false,
},
});
export const settingsWithEmptyAuth = createFixture<GQLSettings>(
+2 -2
View File
@@ -193,7 +193,7 @@ export const validateWholeNumber = createValidator(
*/
export const validateWholeNumberGreaterThan = (x: number) =>
createValidator(
(v) => !v || v === 0 || (Number.isInteger(parseFloat(v)) && v > 0),
(v) => v === null || (Number.isInteger(parseFloat(v)) && v > x),
NOT_A_WHOLE_NUMBER_GREATER_THAN(x)
);
@@ -202,7 +202,7 @@ export const validateWholeNumberGreaterThan = (x: number) =>
*/
export const validateWholeNumberGreaterThanOrEqual = (x: number) =>
createValidator(
(v) => !v || v === 0 || (Number.isInteger(parseFloat(v)) && v >= 0),
(v) => v === null || (Number.isInteger(parseFloat(v)) && v >= x),
NOT_A_WHOLE_NUMBER_GREATER_THAN_OR_EQUAL(x)
);
@@ -0,0 +1,56 @@
import { ReactTestInstance } from "react-test-renderer";
import matchText, { TextMatchOptions, TextMatchPattern } from "./matchText";
const matcher = (pattern: TextMatchPattern, options?: TextMatchOptions) => (
i: ReactTestInstance
) => {
// Only look at dom components.
if (typeof i.type !== "string" || !i.props.title) {
return false;
}
return matchText(pattern, i.props.title, {
collapseWhitespace: false,
...options,
});
};
export function getByTitle(
container: ReactTestInstance,
pattern: TextMatchPattern,
options?: TextMatchOptions
) {
return container.find(matcher(pattern, options));
}
export function getAllByTitle(
container: ReactTestInstance,
pattern: TextMatchPattern,
options?: TextMatchOptions
) {
const results = container.findAll(matcher(pattern, options));
if (!results.length) {
throw new Error(`Couldn't find test id ${pattern}`);
}
return results;
}
export function queryByTitle(
container: ReactTestInstance,
pattern: TextMatchPattern,
options?: TextMatchOptions
) {
const results = container.findAll(matcher(pattern, options));
if (!results.length) {
return null;
}
return results[0];
}
export function queryAllByTitle(
container: ReactTestInstance,
pattern: TextMatchPattern,
options?: TextMatchOptions
) {
return container.findAll(matcher(pattern, options));
}
@@ -1,6 +1,5 @@
import { EventEmitter2 } from "eventemitter2";
import { IResolvers } from "graphql-tools";
import { noop } from "lodash";
import path from "path";
import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
@@ -41,12 +40,8 @@ export interface TestResolvers<T extends Resolvers = any> {
}
function createNodeMock(element: React.ReactElement<any>) {
if (element.type === "div") {
return {
innerHtml: "",
className: "",
focus: noop,
};
if (typeof element.type === "string") {
return document.createElement(element.type);
}
return null;
}
@@ -15,6 +15,12 @@ import {
queryByTestID,
} from "./byTestID";
import { getAllByText, getByText, queryAllByText, queryByText } from "./byText";
import {
getAllByTitle,
getByTitle,
queryAllByTitle,
queryByTitle,
} from "./byTitle";
import {
getAllByType,
getByType,
@@ -70,6 +76,10 @@ export default function within(container: ReactTestInstance) {
queryByType: applyContainer(container, queryByType),
queryParentByType: applyContainer(container, queryParentByType),
queryAllByType: applyContainer(container, queryAllByType),
getByTitle: applyContainer(container, getByTitle),
getAllByTitle: applyContainer(container, getAllByTitle),
queryByTitle: applyContainer(container, queryByTitle),
queryAllByTitle: applyContainer(container, queryAllByTitle),
toJSON: applyContainer(container, toJSON),
toHTML: applyContainer(container, toHTML),
/**
@@ -1,6 +1,8 @@
import React from "react";
import TestRenderer from "react-test-renderer";
import { SPOILER_CLASSNAME } from "coral-common/constants";
import HTMLContent from "./HTMLContent";
it("renders correctly", () => {
@@ -21,3 +23,40 @@ it("sanitizes evil html", () => {
);
expect(renderer.toJSON()).toMatchSnapshot();
});
it("transform spoiler tags", () => {
const revealed = `<span aria-hidden="true">Spoiler</span>`;
const renderer = TestRenderer.create(
<HTMLContent>
{`
<span class="${SPOILER_CLASSNAME}">Spoiler</span>
`}
</HTMLContent>
);
// Check for transformation of unrevealed spoiler.
const html = renderer.toTree()?.rendered?.props.dangerouslySetInnerHTML
.__html;
expect(html).toMatchSnapshot();
// Reveal on click.
const div = document.createElement("div");
div.innerHTML = html;
(global as any).handleCoralSpoilerButton(div, new MouseEvent("click"));
expect(div.innerHTML).toEqual(revealed);
// Not reveal on unrelated keydown.
div.innerHTML = html;
(global as any).handleCoralSpoilerButton(
div,
new KeyboardEvent("keydown", { key: "a" })
);
expect(div.innerHTML).toEqual(html);
// Reveal on enter.
(global as any).handleCoralSpoilerButton(
div,
new KeyboardEvent("keydown", { key: "Enter" })
);
expect(div.innerHTML).toEqual(revealed);
});
@@ -1,14 +1,102 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import { createPurify } from "coral-common/utils/purify";
import { SPOILER_CLASSNAME } from "coral-common/constants";
import {
ALL_FEATURES,
createSanitize,
Sanitize,
} from "coral-common/helpers/sanitize";
import styles from "./HTMLContent.css";
/**
* Create a purify instance that will be used to handle HTML content.
* Sanitize html content and find spoiler tags.
*/
const purify = createPurify(window, false);
const sanitizeAndFindSpoilerTags: (
source: string | Node
) => [HTMLElement, Element[]] = (() => {
/** Resused instance */
let sanitize: Sanitize | null = null;
/** Found spoiler tags during sanitization will be placed here. */
let spoilerTags: Element[] = [];
return (source: string | Node): [HTMLElement, Element[]] => {
if (!sanitize) {
sanitize = createSanitize(window, {
// Allow all rte features to be displayed.
features: ALL_FEATURES,
modify: (purify) => {
// Add a hook that detects spoiler tags and adds to `spoilerTags` array
purify.addHook("afterSanitizeAttributes", (node) => {
if (
node.tagName === "SPAN" &&
node.className === SPOILER_CLASSNAME
) {
spoilerTags.push(node);
}
});
},
});
}
const sanitized = sanitize(source);
const ret = spoilerTags;
spoilerTags = [];
return [sanitized, ret];
};
})();
/**
* Makes sure SpoilerTag Handler is registered in `global`.
*/
function registerSpoilerTagHandler() {
if ("handleCoralSpoilerButton" in global) {
return;
}
// Add global spoiler tag handler `handleCoralSpoilerButton`.
(global as any).handleCoralSpoilerButton = (
el: HTMLElement,
event: MouseEvent | KeyboardEvent
) => {
if (
"key" in event &&
event.key !== " " &&
event.key !== "Enter" &&
event.key !== "Spacebar"
) {
return;
}
// Turn to a dumb span and add the `reveal` css class.
el.innerHTML = el.firstElementChild!.innerHTML;
el.removeAttribute("onClick");
el.removeAttribute("title");
el.removeAttribute("tabindex");
el.removeAttribute("role");
el.classList.add(`${SPOILER_CLASSNAME}-reveal`);
};
}
function transform(source: string | Node) {
// Sanitize source.
const [sanitized, spoilerTags] = sanitizeAndFindSpoilerTags(source);
// Make sure spoiler tag handler exists.
registerSpoilerTagHandler();
// Attach event handlers to spoiler tags.
spoilerTags.forEach((node) => {
node.setAttribute("onmousedown", "javascript: event.preventDefault()");
node.setAttribute("onclick", "handleCoralSpoilerButton(this, event)");
node.setAttribute("onkeydown", "handleCoralSpoilerButton(this, event)");
node.setAttribute("role", "button");
node.setAttribute("title", "Reveal spoiler");
node.setAttribute("tabindex", "0");
node.innerHTML = `<span aria-hidden="true">${node.innerHTML}</span>`;
});
// Return results.
return sanitized.innerHTML;
}
interface HTMLContentProps {
children: string;
@@ -21,7 +109,7 @@ const HTMLContent: FunctionComponent<HTMLContentProps> = ({
}) => (
<div
className={cn(styles.root, className)}
dangerouslySetInnerHTML={{ __html: purify.sanitize(children) }}
dangerouslySetInnerHTML={{ __html: transform(children) }}
/>
);
@@ -23,3 +23,9 @@ exports[`sanitizes evil html 1`] = `
}
/>
`;
exports[`transform spoiler tags 1`] = `
"
<span class=\\"coral-rte-spoiler\\" onmousedown=\\"javascript: event.preventDefault()\\" onclick=\\"handleCoralSpoilerButton(this, event)\\" onkeydown=\\"handleCoralSpoilerButton(this, event)\\" role=\\"button\\" title=\\"Reveal spoiler\\" tabindex=\\"0\\"><span aria-hidden=\\"true\\">Spoiler</span></span>
"
`;
@@ -30,4 +30,17 @@
color: var(--palette-primary-dark);
}
}
& :global(.coral-rte-spoiler) {
background-color: var(--palette-text-dark);
transition: background 300ms;
border-radius: var(--round-corners);
color: rgba(0, 0, 0, 0);
cursor: pointer;
}
& :global(.coral-rte-spoiler-reveal) {
opacity: 1.0;
color: var(--palette-text-dark);
background-color: transparent;
cursor: text;
}
}
@@ -18,10 +18,14 @@ import {
MessageIcon,
RelativeTime,
} from "coral-ui/components";
import { PropTypesOf } from "coral-ui/types";
import { getCommentBodyValidators, normalizeRTEHTML } from "../../helpers";
import {
getCommentBodyValidators,
getHTMLCharacterLength,
} from "../../helpers";
import RemainingCharactersContainer from "../../RemainingCharacters";
import RTE from "../../RTE";
import RTEContainer from "../../RTE";
import TopBarLeft from "../TopBarLeft";
import Username from "../Username";
@@ -45,6 +49,7 @@ export interface EditCommentFormProps {
expired?: boolean;
min: number | null;
max: number | null;
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
}
const EditCommentForm: FunctionComponent<EditCommentFormProps> = (props) => {
@@ -85,14 +90,13 @@ const EditCommentForm: FunctionComponent<EditCommentFormProps> = (props) => {
id="comments-editCommentForm-rte"
attrs={{ placeholder: true }}
>
<RTE
inputId={inputID}
onChange={({ html }) =>
input.onChange(normalizeRTEHTML(html))
}
<RTEContainer
config={props.rteConfig}
inputID={inputID}
onChange={(html) => input.onChange(html)}
value={input.value}
placeholder="Edit comment"
forwardRef={props.rteRef}
ref={props.rteRef}
disabled={submitting || props.expired}
/>
</Localized>
@@ -180,7 +184,9 @@ const EditCommentForm: FunctionComponent<EditCommentFormProps> = (props) => {
color="primary"
variant="filled"
disabled={
submitting || !input.value || pristine
submitting ||
getHTMLCharacterLength(input.value) === 0 ||
pristine
}
type="submit"
fullWidth={matches}
@@ -134,6 +134,7 @@ export class EditCommentFormContainer extends Component<Props, State> {
return (
<EditCommentForm
id={this.props.comment.id}
rteConfig={this.props.settings.rte}
onSubmit={this.handleOnSubmit}
initialValues={this.intitialValues}
onCancel={this.handleOnCancelOrClose}
@@ -189,6 +190,9 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
min
max
}
rte {
...RTEContainer_config
}
}
`,
})(EditCommentFormContainer)
@@ -23,10 +23,14 @@ import {
HorizontalGutter,
MatchMedia,
} from "coral-ui/components";
import { PropTypesOf } from "coral-ui/types";
import { getCommentBodyValidators, normalizeRTEHTML } from "../../helpers";
import {
getCommentBodyValidators,
getHTMLCharacterLength,
} from "../../helpers";
import RemainingCharactersContainer from "../../RemainingCharacters";
import RTE from "../../RTE";
import RTEContainer from "../../RTE";
import ReplyTo from "./ReplyTo";
interface FormProps {
@@ -46,6 +50,7 @@ export interface ReplyCommentFormProps {
max: number | null;
disabled?: boolean;
disabledMessage?: React.ReactNode;
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
}
const ReplyCommentForm: FunctionComponent<ReplyCommentFormProps> = (props) => {
@@ -88,15 +93,14 @@ const ReplyCommentForm: FunctionComponent<ReplyCommentFormProps> = (props) => {
id="comments-replyCommentForm-rte"
attrs={{ placeholder: true }}
>
<RTE
inputId={inputID}
<RTEContainer
config={props.rteConfig}
inputID={inputID}
onFocus={onFocus}
onChange={({ html }) =>
input.onChange(normalizeRTEHTML(html))
}
onChange={(html) => input.onChange(html)}
value={input.value}
placeholder="Write a reply"
forwardRef={props.rteRef}
ref={props.rteRef}
disabled={submitting || props.disabled}
/>
</Localized>
@@ -157,7 +161,9 @@ const ReplyCommentForm: FunctionComponent<ReplyCommentFormProps> = (props) => {
color="primary"
variant="filled"
disabled={
submitting || !input.value || props.disabled
submitting ||
getHTMLCharacterLength(input.value) === 0 ||
props.disabled
}
type="submit"
className={CLASSES.createReplyComment.submit}
@@ -55,6 +55,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
enabled: false,
message: "",
},
rte: {},
},
},
add
@@ -106,7 +107,7 @@ it("save values", async () => {
it("creates a comment", async () => {
const storyID = "story-id";
const input = { body: "Hello World!", local: false };
const input = { body: "Hello World!" };
const createCommentStub = sinon.stub().returns({ edge: { node: {} } });
const form = { reset: noop };
const onCloseStub = sinon.stub();
@@ -131,6 +132,7 @@ it("creates a comment", async () => {
parentID: props.comment.id,
parentRevisionID: "revision-id",
nudge: true,
local: undefined,
...input,
})
).toBeTruthy();
@@ -114,7 +114,7 @@ export class ReplyCommentFormContainer extends Component<Props, State> {
parentRevisionID: this.props.comment.revision!.id,
local: this.props.localReply,
nudge: this.state.nudge,
...input,
body: input.body,
})
);
if (submitStatus !== "RETRY") {
@@ -176,6 +176,7 @@ export class ReplyCommentFormContainer extends Component<Props, State> {
return (
<ReplyCommentForm
id={this.props.comment.id}
rteConfig={this.props.settings.rte}
onSubmit={this.handleOnSubmit}
onChange={this.handleOnChange}
initialValues={this.state.initialValues}
@@ -229,6 +230,9 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
closeCommenting {
message
}
rte {
...RTEContainer_config
}
}
`,
story: graphql`
@@ -11,6 +11,7 @@ exports[`renders correctly 1`] = `
onChange={[Function]}
onSubmit={[Function]}
parentUsername="Joe"
rteConfig={Object {}}
rteRef={[Function]}
/>
`;
@@ -26,6 +27,7 @@ exports[`renders when commenting has been disabled 1`] = `
onChange={[Function]}
onSubmit={[Function]}
parentUsername="Joe"
rteConfig={Object {}}
rteRef={[Function]}
/>
`;
@@ -41,6 +43,7 @@ exports[`renders when story has been closed 1`] = `
onChange={[Function]}
onSubmit={[Function]}
parentUsername="Joe"
rteConfig={Object {}}
rteRef={[Function]}
/>
`;
@@ -61,6 +64,7 @@ exports[`renders with initialValues 1`] = `
onChange={[Function]}
onSubmit={[Function]}
parentUsername="Joe"
rteConfig={Object {}}
rteRef={[Function]}
/>
`;
@@ -1,12 +1,22 @@
.content {
composes: root from "~coral-stream/shared/htmlContent.css";
border: 1px solid var(--palette-grey-main);
& :global(.coral-rte-spoiler) {
background-color: black;
color: white;
padding: 0 2px;
cursor: text;
}
}
.toolbar {
background-color: var(--palette-common-white);
border: 1px solid var(--palette-grey-main);
}
.toolbarHidden {
display: none;
}
.placeholder {
composes: placeholder from "~coral-ui/shared/typography.css";
+147 -36
View File
@@ -1,35 +1,83 @@
import { Blockquote, Bold, CoralRTE, Italic } from "@coralproject/rte";
import {
Blockquote,
Bold,
CoralRTE,
Italic,
Spoiler,
Strike,
UnorderedList,
} from "@coralproject/rte";
import { Localized as LocalizedOriginal } from "@fluent/react/compat";
import cn from "classnames";
import React, { EventHandler, FocusEvent, FunctionComponent, Ref } from "react";
import React, {
EventHandler,
FocusEvent,
FunctionComponent,
Ref,
useMemo,
} from "react";
import { createSanitize } from "coral-common/helpers/sanitize";
import CLASSES from "coral-stream/classes";
import { Icon } from "coral-ui/components";
import { PropTypesOf } from "coral-ui/types";
import styles from "./RTE.css";
interface RTEFeatures {
bold?: boolean;
italic?: boolean;
blockquote?: boolean;
strikethrough?: boolean;
bulletList?: boolean;
spoiler?: boolean;
}
const createSanitizeToDOMFragment = (features: RTEFeatures = {}) => {
/** Resused Sanitize instance */
const sanitize = createSanitize(window, {
features: {
bold: features.bold,
italic: features.italic,
blockquote: features.blockquote,
bulletList: features.bulletList,
strikethrough: features.strikethrough,
spoiler: features.spoiler,
},
});
return (html: string) => {
const frag = document.createDocumentFragment();
if (html) {
const sanitized = sanitize(html);
while (sanitized.firstChild) {
frag.appendChild(sanitized.firstChild);
}
}
return frag;
};
};
// Use a special Localized version that forwards
// ref and passes the api prop to the children.
// This is currently required in order for the RTE
// to detect and setup the features.
const Localized = React.forwardRef<any, PropTypesOf<typeof LocalizedOriginal>>(
function RTELocalized({ api, ...props }, ref) {
function RTELocalized({ ctrlKey, squire, ...props }, ref) {
return (
<LocalizedOriginal {...props}>
{React.cloneElement(
React.Children.only(props.children as React.ReactElement),
{ api, ref }
{ ctrlKey, squire, ref }
)}
</LocalizedOriginal>
);
}
);
export interface RTEProps {
inputId?: string;
interface Props {
inputID?: string;
/**
* The content value of the component.
* The default content value of the component.
*/
defaultValue?: string;
/**
@@ -66,43 +114,23 @@ export interface RTEProps {
/**
* onChange
*/
onChange?: (data: { html: string; text: string }) => void;
onChange?: (html: string) => void;
onFocus?: EventHandler<FocusEvent>;
onBlur?: EventHandler<FocusEvent>;
disabled?: boolean;
forwardRef?: Ref<CoralRTE>;
features?: RTEFeatures;
}
const features = [
<Localized key="bold" id="comments-rte-bold" attrs={{ title: true }}>
<Bold>
<Icon size="md">format_bold</Icon>
</Bold>
</Localized>,
<Localized key="italic" id="comments-rte-italic" attrs={{ title: true }}>
<Italic>
<Icon size="md">format_italic</Icon>
</Italic>
</Localized>,
<Localized
key="blockquote"
id="comments-rte-blockquote"
attrs={{ title: true }}
>
<Blockquote key="blockquote">
<Icon size="md">format_quote</Icon>
</Blockquote>
</Localized>,
];
const RTE: FunctionComponent<RTEProps> = (props) => {
const RTE: FunctionComponent<Props> = (props) => {
const {
className,
fullWidth,
value,
inputId,
inputID,
placeholder,
onChange,
disabled,
@@ -113,12 +141,91 @@ const RTE: FunctionComponent<RTEProps> = (props) => {
toolbarClassName,
onFocus,
onBlur,
features,
...rest
} = props;
const sanitizeToDOMFragment = useMemo(() => {
return createSanitizeToDOMFragment(features);
}, [features]);
const featureElements = useMemo(() => {
const x = [];
if (features?.bold) {
x.push(
<Localized key="bold" id="comments-rte-bold" attrs={{ title: true }}>
<Bold>
<Icon size="md">format_bold</Icon>
</Bold>
</Localized>
);
}
if (features?.italic) {
x.push(
<Localized
key="italic"
id="comments-rte-italic"
attrs={{ title: true }}
>
<Italic>
<Icon size="md">format_italic</Icon>
</Italic>
</Localized>
);
}
if (features?.blockquote) {
x.push(
<Localized
key="blockquote"
id="comments-rte-blockquote"
attrs={{ title: true }}
>
<Blockquote>
<Icon size="md">format_quote</Icon>
</Blockquote>
</Localized>
);
}
if (features?.bulletList) {
x.push(
<Localized
key="bulletedList"
id="comments-rte-bulletedList"
attrs={{ title: true }}
>
<UnorderedList>
<Icon size="md">format_list_bulleted</Icon>
</UnorderedList>
</Localized>
);
}
if (features?.strikethrough) {
x.push(
<Localized
key="strikethrough"
id="comments-rte-strikethrough"
attrs={{ title: true }}
>
<Strike>
<Icon size="md">strikethrough_s</Icon>
</Strike>
</Localized>
);
}
if (features?.spoiler) {
x.push(
<Localized key="spoiler" id="comments-rte-spoiler">
<Spoiler>Spoiler</Spoiler>
</Localized>
);
}
return x;
}, [features]);
return (
<div>
<CoralRTE
inputId={inputId}
inputID={inputID}
className={cn(CLASSES.rte, className)}
contentClassName={cn(
CLASSES.rte.content,
@@ -133,17 +240,21 @@ const RTE: FunctionComponent<RTEProps> = (props) => {
toolbarClassName={cn(
CLASSES.rte.toolbar,
toolbarClassName,
styles.toolbar
styles.toolbar,
{
[styles.toolbarHidden]: featureElements.length === 0,
}
)}
onChange={onChange}
value={value || defaultValue}
value={value || defaultValue || "<div><br></div>"}
disabled={disabled}
placeholder={placeholder}
features={features}
features={featureElements}
ref={forwardRef}
toolbarPosition="bottom"
onBlur={onBlur}
onFocus={onFocus}
sanitizeToDOMFragment={sanitizeToDOMFragment}
{...rest}
/>
</div>
@@ -0,0 +1,37 @@
import { CoralRTE } from "@coralproject/rte";
import React, { Ref } from "react";
import { graphql } from "react-relay";
import { convertGQLRTEConfigToRTEFeatures } from "coral-common/helpers/sanitize";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { withForwardRef } from "coral-ui/hocs";
import { PropTypesOf } from "coral-ui/types";
import { RTEContainer_config } from "coral-stream/__generated__/RTEContainer_config.graphql";
import RTE from "./RTE";
interface Props extends Omit<PropTypesOf<typeof RTE>, "ref"> {
forwardRef: Ref<CoralRTE>;
config: RTEContainer_config;
}
const RTEContainer: React.FunctionComponent<Props> = ({ config, ...rest }) => {
const features: Partial<
PropTypesOf<typeof RTE>["features"]
> = convertGQLRTEConfigToRTEFeatures(config);
return <RTE features={features} {...rest} />;
};
const enhanced = withForwardRef(
withFragmentContainer<Props>({
config: graphql`
fragment RTEContainer_config on RTEConfiguration {
enabled
strikethrough
spoiler
}
`,
})(RTEContainer)
);
export default enhanced;
@@ -9,62 +9,13 @@ exports[`renders correctly 1`] = `
contentClassNameDisabled=""
contentContainerClassName=""
contentContainerClassNameDisabled=""
features={
Array [
<ForwardRef(RTELocalized)
attrs={
Object {
"title": true,
}
}
id="comments-rte-bold"
>
<Toggle>
<ForwardRef(forwardRef)
size="md"
>
format_bold
</ForwardRef(forwardRef)>
</Toggle>
</ForwardRef(RTELocalized)>,
<ForwardRef(RTELocalized)
attrs={
Object {
"title": true,
}
}
id="comments-rte-italic"
>
<Toggle>
<ForwardRef(forwardRef)
size="md"
>
format_italic
</ForwardRef(forwardRef)>
</Toggle>
</ForwardRef(RTELocalized)>,
<ForwardRef(RTELocalized)
attrs={
Object {
"title": true,
}
}
id="comments-rte-blockquote"
>
<Toggle>
<ForwardRef(forwardRef)
size="md"
>
format_quote
</ForwardRef(forwardRef)>
</Toggle>
</ForwardRef(RTELocalized)>,
]
}
features={Array []}
placeholder="Post a comment"
placeholderClassName="coral coral-rte-placeholder RTE-placeholder"
placeholderClassNameDisabled=""
toolbarClassName="coral coral-rte-toolbar RTE-toolbar"
sanitizeToDOMFragment={[Function]}
sanitizeValue={true}
toolbarClassName="coral coral-rte-toolbar RTE-toolbar RTE-toolbarHidden"
toolbarClassNameDisabled=""
toolbarPosition="bottom"
value="Hello world"
@@ -1 +1,2 @@
export { default } from "./RTE";
export { default as RTE } from "./RTE";
export { default } from "./RTEContainer";
@@ -13,9 +13,12 @@ import ValidationMessage from "coral-stream/common/ValidationMessage";
import { CreateCommentFocusEvent } from "coral-stream/events";
import { AriaInfo, Button, Flex, HorizontalGutter } from "coral-ui/components";
import { getCommentBodyValidators, normalizeRTEHTML } from "../../helpers";
import {
getCommentBodyValidators,
getHTMLCharacterLength,
} from "../../helpers";
import RemainingCharactersContainer from "../../RemainingCharacters";
import RTE from "../../RTE";
import RTEContainer from "../../RTE";
import MessageBoxContainer from "../MessageBoxContainer";
import PostCommentSubmitStatusContainer from "./PostCommentSubmitStatusContainer";
@@ -44,6 +47,7 @@ interface Props {
submitStatus: PropTypesOf<PostCommentSubmitStatusContainer>["status"];
showMessageBox?: boolean;
story: PropTypesOf<typeof MessageBoxContainer>["story"] & StorySettings;
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
}
const PostCommentForm: FunctionComponent<Props> = (props) => {
@@ -108,12 +112,11 @@ const PostCommentForm: FunctionComponent<Props> = (props) => {
}
attrs={{ placeholder: true }}
>
<RTE
inputId="comments-postCommentForm-field"
<RTEContainer
inputID="comments-postCommentForm-field"
config={props.rteConfig}
onFocus={onFocus}
onChange={({ html }) =>
input.onChange(normalizeRTEHTML(html))
}
onChange={(html) => input.onChange(html)}
contentClassName={
props.showMessageBox
? styles.rteBorderless
@@ -166,7 +169,9 @@ const PostCommentForm: FunctionComponent<Props> = (props) => {
variant="filled"
className={CLASSES.createComment.submit}
disabled={
submitting || !input.value || props.disabled
submitting ||
getHTMLCharacterLength(input.value) === 0 ||
props.disabled
}
type="submit"
>
@@ -47,6 +47,11 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
enabled: false,
message: "",
},
rte: {
enabled: true,
strikethrough: false,
spoiler: false,
},
},
viewer: {
id: "viewer-id",
@@ -130,7 +130,7 @@ export class PostCommentFormContainer extends Component<Props, State> {
storyID: this.props.story.id,
nudge: this.state.nudge,
commentsOrderBy: this.props.commentsOrderBy,
...input,
body: input.body,
})
);
if (submitStatus !== "RETRY") {
@@ -211,6 +211,7 @@ export class PostCommentFormContainer extends Component<Props, State> {
if (!this.props.viewer) {
return (
<PostCommentFormFake
rteConfig={this.props.settings.rte}
draft={this.state.notLoggedInDraft}
onDraftChange={this.handleDraftChange}
story={this.props.story}
@@ -231,6 +232,7 @@ export class PostCommentFormContainer extends Component<Props, State> {
onSubmit={this.handleOnSubmit}
onChange={this.handleOnChange}
initialValues={this.state.initialValues}
rteConfig={this.props.settings.rte}
min={
(this.props.settings.charCount.enabled &&
this.props.settings.charCount.min) ||
@@ -285,6 +287,9 @@ const enhanced = withContext(({ sessionStorage }) => ({
closeCommenting {
message
}
rte {
...RTEContainer_config
}
}
`,
story: graphql`
@@ -12,6 +12,7 @@ it("renders correctly", () => {
const renderer = createRenderer();
renderer.render(
<PostCommentFormFakeN
rteConfig={{}}
story={{}}
draft=""
onDraftChange={noop}
@@ -9,7 +9,7 @@ import CLASSES from "coral-stream/classes";
import { CreateCommentFocusEvent } from "coral-stream/events";
import { Button, HorizontalGutter } from "coral-ui/components";
import RTE from "../../RTE";
import RTEContainer from "../../RTE";
import MessageBoxContainer from "../MessageBoxContainer";
import styles from "./PostCommentFormFake.css";
@@ -26,6 +26,7 @@ interface Props {
draft: string;
onDraftChange: (draft: string) => void;
onSignIn: () => void;
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
}
const PostCommentFormFake: FunctionComponent<Props> = (props) => {
@@ -33,10 +34,9 @@ const PostCommentFormFake: FunctionComponent<Props> = (props) => {
const onFocus = useCallback(() => {
emitFocusEvent();
}, [emitFocusEvent]);
const onChange = useCallback(
(data: { html: string; text: string }) => props.onDraftChange(data.html),
[props.onDraftChange]
);
const onChange = useCallback((html: string) => props.onDraftChange(html), [
props.onDraftChange,
]);
const isQA =
props.story.settings && props.story.settings.mode === GQLSTORY_MODE.QA;
return (
@@ -57,7 +57,8 @@ const PostCommentFormFake: FunctionComponent<Props> = (props) => {
}
attrs={{ placeholder: true }}
>
<RTE
<RTEContainer
config={props.rteConfig}
placeholder={isQA ? "Post a question" : "Post a comment"}
value={props.draft}
onChange={onChange}
@@ -13,6 +13,13 @@ exports[`renders correctly 1`] = `
min={3}
onChange={[Function]}
onSubmit={[Function]}
rteConfig={
Object {
"enabled": true,
"spoiler": false,
"strikethrough": false,
}
}
showMessageBox={false}
story={
Object {
@@ -62,6 +69,13 @@ exports[`renders when commenting has been disabled (non-collapsing) 1`] = `
min={3}
onChange={[Function]}
onSubmit={[Function]}
rteConfig={
Object {
"enabled": true,
"spoiler": false,
"strikethrough": false,
}
}
showMessageBox={false}
story={
Object {
@@ -111,6 +125,13 @@ exports[`renders when story has been closed (non-collapsing) 1`] = `
min={3}
onChange={[Function]}
onSubmit={[Function]}
rteConfig={
Object {
"enabled": true,
"spoiler": false,
"strikethrough": false,
}
}
showMessageBox={false}
story={
Object {
@@ -147,6 +168,13 @@ exports[`renders when user is scheduled to be deleted 1`] = `
min={3}
onChange={[Function]}
onSubmit={[Function]}
rteConfig={
Object {
"enabled": true,
"spoiler": false,
"strikethrough": false,
}
}
showMessageBox={false}
story={
Object {
@@ -177,6 +205,13 @@ exports[`renders with initialValues 1`] = `
min={3}
onChange={[Function]}
onSubmit={[Function]}
rteConfig={
Object {
"enabled": true,
"spoiler": false,
"strikethrough": false,
}
}
showMessageBox={false}
story={
Object {
@@ -20,7 +20,8 @@ exports[`renders correctly 1`] = `
}
id="comments-postCommentFormFake-rte"
>
<RTE
<ForwardRef(forwardRef)
config={Object {}}
onChange={[Function]}
onFocus={[Function]}
placeholder="Post a comment"
@@ -1,8 +1,7 @@
import getHTMLText from "./getHTMLText";
import getHTMLPlainText from "coral-common/helpers/getHTMLPlainText";
/**
* getHTMLCharacterLength will strip all tags and return remaining
* character length.
* getHTMLCharacterLength will return current character length.
*
* @param html the html which length should be determined
*/
@@ -10,6 +9,5 @@ export default function getHTMLCharacterLength(html: string | undefined) {
if (!html) {
return 0;
}
const innerText = getHTMLText(html);
return innerText.trim().replace(/\n/g, "").length;
return getHTMLPlainText(html).length;
}
@@ -1,17 +0,0 @@
/**
* getHTMLText returns text representation of html.
* Includes a different implementation during test that works.
*
* @param html string
*/
export default function getHTMLText(html: string) {
if (process.env.NODE_ENV === "test") {
// innerText is not implement in JSDOM, so we use `striptags` instead.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const striptags = require("striptags");
return striptags(html.replace(/<br *\/?>/, "\n"));
}
const divElement = document.createElement("div");
divElement.innerHTML = html;
return divElement.innerText;
}
@@ -1,8 +1,6 @@
export { default as getHTMLCharacterLength } from "./getHTMLCharacterLength";
export { default as getCommentBodyValidators } from "./getCommentBodyValidators";
export { default as normalizeRTEHTML } from "./normalizeRTEHTML";
export { default as shouldTriggerSettingsRefresh } from "./shouldTriggerSettingsRefresh";
export { default as getHTMLText } from "./getHTMLText";
export { default as getSubmitStatus, SubmitStatus } from "./getSubmitStatus";
export { default as incrementStoryCommentCounts } from "./incrementStoryCommentCounts";
export { default as isInReview } from "./isInReview";
@@ -1,31 +0,0 @@
import getHTMLText from "./getHTMLText";
/**
* cleanupRTEEmptyHTML will try to figure out if given html only contains
* dead tags like `<b></b>` or `<br />` or `<i></i><br />` which basically
* means renders nothing and return a standardized `""` instead.
*
* @param html the html to be cleaned up
*/
function cleanupRTEEmptyHTML(html: string) {
if (html.includes("blockquote")) {
return html;
}
const innerText = getHTMLText(html);
if (
(innerText !== "\n" && innerText.includes("\n")) ||
innerText.trim() !== ""
) {
return html;
}
return "";
}
export default function normalizeRTEHTML(html: string) {
// IE11 uses strong and em instead of b and i.
html = html.replace("<strong>", "<b>");
html = html.replace("</strong>", "</b>");
html = html.replace("<em>", "<i>");
html = html.replace("</em>", "</i>");
return cleanupRTEEmptyHTML(html);
}
@@ -96,38 +96,24 @@ exports[`renders comment stream 1`] = `
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
className="RTE.module-contentEditableContainer"
>
<div
aria-placeholder="Post a comment"
className="RTE-contentEditable coral coral-rte-content RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
}
}
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
/>
<div
aria-hidden="true"
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
>
Post a comment
</div>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
@@ -141,7 +127,7 @@ exports[`renders comment stream 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
@@ -155,7 +141,7 @@ exports[`renders comment stream 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
@@ -168,6 +154,20 @@ exports[`renders comment stream 1`] = `
format_quote
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bulleted List"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_list_bulleted
</i>
</button>
</div>
</div>
</div>
@@ -126,7 +126,7 @@ it("post a reply", async () => {
expect(await within(form).axe()).toHaveNoViolations();
// Write reply .
act(() => rte.props.onChange({ html: "<b>Hello world!</b>" }));
act(() => rte.props.onChange("<b>Hello world!</b>"));
act(() => {
form.props.onSubmit();
});
@@ -306,34 +306,19 @@ exports[`edit a comment and handle server error: edit form 1`] = `
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
className="RTE.module-contentEditableContainer"
>
<div
aria-placeholder="Edit comment"
className="RTE-contentEditable coral coral-rte-content RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "I like yoghurt",
}
}
disabled={false}
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
id="comments-editCommentForm-rte-comment-with-replies"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
@@ -347,7 +332,7 @@ exports[`edit a comment and handle server error: edit form 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
@@ -361,7 +346,7 @@ exports[`edit a comment and handle server error: edit form 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
@@ -374,6 +359,20 @@ exports[`edit a comment and handle server error: edit form 1`] = `
format_quote
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bulleted List"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_list_bulleted
</i>
</button>
</div>
</div>
</div>
@@ -496,34 +495,19 @@ exports[`edit a comment: edit form 1`] = `
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
className="RTE.module-contentEditableContainer"
>
<div
aria-placeholder="Edit comment"
className="RTE-contentEditable coral coral-rte-content RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "I like yoghurt",
}
}
disabled={false}
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
id="comments-editCommentForm-rte-comment-with-replies"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
@@ -537,7 +521,7 @@ exports[`edit a comment: edit form 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
@@ -551,7 +535,7 @@ exports[`edit a comment: edit form 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
@@ -564,6 +548,20 @@ exports[`edit a comment: edit form 1`] = `
format_quote
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bulleted List"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_list_bulleted
</i>
</button>
</div>
</div>
</div>
@@ -686,34 +684,19 @@ exports[`edit a comment: optimistic response 1`] = `
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
className="RTE.module-contentEditableContainer RTE.module-contentEditableContainerDisabled"
>
<div
aria-placeholder="Edit comment"
className="RTE-contentEditable coral coral-rte-content RTE-content RTE-contentEditableDisabled"
contentEditable={false}
dangerouslySetInnerHTML={
Object {
"__html": "Edited!",
}
}
disabled={true}
className="RTE.module-contentEditable coral coral-rte-content RTE-content RTE.module-contentEditableDisabled"
id="comments-editCommentForm-rte-comment-with-replies"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarDisabled RTE-toolbarBottom Toolbar-toolbar"
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarDisabled RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
@@ -727,7 +710,7 @@ exports[`edit a comment: optimistic response 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
@@ -741,7 +724,7 @@ exports[`edit a comment: optimistic response 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
@@ -754,6 +737,20 @@ exports[`edit a comment: optimistic response 1`] = `
format_quote
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bulleted List"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_list_bulleted
</i>
</button>
</div>
</div>
</div>
@@ -1626,34 +1623,19 @@ exports[`shows expiry message: edit time expired 1`] = `
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
className="RTE.module-contentEditableContainer RTE.module-contentEditableContainerDisabled"
>
<div
aria-placeholder="Edit comment"
className="RTE-contentEditable coral coral-rte-content RTE-content RTE-contentEditableDisabled"
contentEditable={false}
dangerouslySetInnerHTML={
Object {
"__html": "I like yoghurt",
}
}
disabled={true}
className="RTE.module-contentEditable coral coral-rte-content RTE-content RTE.module-contentEditableDisabled"
id="comments-editCommentForm-rte-comment-with-replies"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarDisabled RTE-toolbarBottom Toolbar-toolbar"
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarDisabled RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
@@ -1667,7 +1649,7 @@ exports[`shows expiry message: edit time expired 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
@@ -1681,7 +1663,7 @@ exports[`shows expiry message: edit time expired 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
@@ -1694,6 +1676,20 @@ exports[`shows expiry message: edit time expired 1`] = `
format_quote
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bulleted List"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_list_bulleted
</i>
</button>
</div>
</div>
</div>
@@ -321,40 +321,25 @@ exports[`post a reply: open reply form 1`] = `
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
className="RTE.module-contentEditableContainer"
>
<div
aria-placeholder="Write a reply"
className="RTE-contentEditable coral coral-rte-content RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
}
}
disabled={false}
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
id="comments-replyCommentForm-rte-comment-with-deepest-replies-3"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
<div
aria-hidden="true"
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
>
Write a reply
</div>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
@@ -368,7 +353,7 @@ exports[`post a reply: open reply form 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
@@ -382,7 +367,7 @@ exports[`post a reply: open reply form 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
@@ -395,6 +380,20 @@ exports[`post a reply: open reply form 1`] = `
format_quote
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bulleted List"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_list_bulleted
</i>
</button>
</div>
</div>
</div>
@@ -277,40 +277,25 @@ exports[`post a reply: open reply form 1`] = `
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
className="RTE.module-contentEditableContainer"
>
<div
aria-placeholder="Write a reply"
className="RTE-contentEditable coral coral-rte-content RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
}
}
disabled={false}
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
id="comments-replyCommentForm-rte-comment-0"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
<div
aria-hidden="true"
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
>
Write a reply
</div>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
@@ -324,7 +309,7 @@ exports[`post a reply: open reply form 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
@@ -338,7 +323,7 @@ exports[`post a reply: open reply form 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
@@ -351,6 +336,20 @@ exports[`post a reply: open reply form 1`] = `
format_quote
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bulleted List"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_list_bulleted
</i>
</button>
</div>
</div>
</div>
@@ -74,38 +74,24 @@ exports[`renders comment stream with community guidelines 1`] = `
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
className="RTE.module-contentEditableContainer"
>
<div
aria-placeholder="Post a comment"
className="RTE-contentEditable coral coral-rte-content RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
}
}
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
/>
<div
aria-hidden="true"
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
>
Post a comment
</div>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
@@ -119,7 +105,7 @@ exports[`renders comment stream with community guidelines 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
@@ -133,7 +119,7 @@ exports[`renders comment stream with community guidelines 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
@@ -146,6 +132,20 @@ exports[`renders comment stream with community guidelines 1`] = `
format_quote
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bulleted List"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_list_bulleted
</i>
</button>
</div>
</div>
</div>
@@ -316,40 +316,25 @@ exports[`renders message box when logged in 1`] = `
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
className="RTE.module-contentEditableContainer"
>
<div
aria-placeholder="Post a comment"
className="RTE-contentEditable coral coral-rte-content PostCommentForm-rteBorderless RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
}
}
disabled={false}
className="RTE.module-contentEditable coral coral-rte-content PostCommentForm-rteBorderless RTE-content"
id="comments-postCommentForm-field"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
<div
aria-hidden="true"
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
>
Post a comment
</div>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
@@ -363,7 +348,7 @@ exports[`renders message box when logged in 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
@@ -377,7 +362,7 @@ exports[`renders message box when logged in 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
@@ -390,6 +375,20 @@ exports[`renders message box when logged in 1`] = `
format_quote
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bulleted List"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_list_bulleted
</i>
</button>
</div>
</div>
</div>
@@ -623,38 +622,24 @@ exports[`renders message box when not logged in 1`] = `
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
className="RTE.module-contentEditableContainer"
>
<div
aria-placeholder="Post a comment"
className="RTE-contentEditable coral coral-rte-content RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
}
}
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
/>
<div
aria-hidden="true"
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
>
Post a comment
</div>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
@@ -668,7 +653,7 @@ exports[`renders message box when not logged in 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
@@ -682,7 +667,7 @@ exports[`renders message box when not logged in 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
@@ -695,6 +680,20 @@ exports[`renders message box when not logged in 1`] = `
format_quote
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bulleted List"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_list_bulleted
</i>
</button>
</div>
</div>
</div>
@@ -96,38 +96,24 @@ exports[`renders comment stream 1`] = `
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
className="RTE.module-contentEditableContainer"
>
<div
aria-placeholder="Post a comment"
className="RTE-contentEditable coral coral-rte-content RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
}
}
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
/>
<div
aria-hidden="true"
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
>
Post a comment
</div>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
@@ -141,7 +127,7 @@ exports[`renders comment stream 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
@@ -155,7 +141,7 @@ exports[`renders comment stream 1`] = `
</i>
</button>
<button
className="Button-button"
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
@@ -168,6 +154,20 @@ exports[`renders comment stream 1`] = `
format_quote
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bulleted List"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_list_bulleted
</i>
</button>
</div>
</div>
</div>
@@ -1,4 +1,3 @@
import RTE from "@coralproject/rte";
import sinon from "sinon";
import timekeeper from "timekeeper";
@@ -12,6 +11,7 @@ import {
waitForElement,
within,
} from "coral-framework/testHelpers";
import waitForRTE from "coral-stream/test/helpers/waitForRTE";
import { commenters, settings, stories } from "../../fixtures";
import create from "./create";
@@ -61,18 +61,12 @@ async function createTestRenderer(
);
// Open edit form.
within(comment).getByText("Edit").props.onClick();
act(() => {
within(comment).getByText("Edit").props.onClick();
});
const rte = await waitForElement(
() =>
findParentWithType(
within(comment).getByLabelText("Edit comment"),
// We'll use the RTE component here as an exception because the
// jsdom does not support all of what is needed for rendering the
// Rich Text Editor.
RTE
)!
);
// Wait for edit RTE to initialize.
const rte = await waitForRTE(comment, "Edit comment");
const form = findParentWithType(rte, "form")!;
return {
@@ -89,7 +83,7 @@ it("validate min", async () => {
const text = "Please enter at least 3 characters.";
act(() => rte.props.onChange({ html: "ab" }));
act(() => rte.props.onChange("ab"));
act(() => {
form.props.onSubmit();
});
@@ -101,7 +95,7 @@ it("validate max", async () => {
const text = "Please enter at max 10 characters.";
act(() => rte.props.onChange({ html: "abcdefghijklmnopqrst" }));
act(() => rte.props.onChange("abcdefghijklmnopqrst"));
act(() => {
form.props.onSubmit();
});
@@ -111,9 +105,9 @@ it("validate max", async () => {
it("show remaining characters", async () => {
const { rte, form } = await createTestRenderer();
act(() => rte.props.onChange({ html: "abc" }));
act(() => rte.props.onChange("abc"));
within(form).getByText("7 characters remaining");
act(() => rte.props.onChange({ html: "abcdefghijkl" }));
act(() => rte.props.onChange("abcdefghijkl"));
within(form).getByText("-2 characters remaining");
});
@@ -153,9 +147,9 @@ it("update from server upon specific char count error", async () => {
{ muteNetworkErrors: true }
);
act(() => rte.props.onChange({ html: "abc" }));
act(() => rte.props.onChange("abc"));
within(form).getByText("7 characters remaining");
act(() => rte.props.onChange({ html: "abcdefgh" }));
act(() => rte.props.onChange("abcdefgh"));
within(form).getByText("2 characters remaining");
act(() => {
form.props.onSubmit();
@@ -167,7 +161,7 @@ it("update from server upon specific char count error", async () => {
});
// Body submit error should be displayed.
within(form).getByText(errorCode);
act(() => rte.props.onChange({ html: "abcde" }));
act(() => rte.props.onChange("abcde"));
// Body submit error should disappear when form gets dirty.
expect(within(form).queryByText(errorCode)).toBeNull();
@@ -1,4 +1,3 @@
import RTE from "@coralproject/rte";
import sinon from "sinon";
import { ERROR_CODES } from "coral-common/errors";
@@ -12,6 +11,7 @@ import {
waitForElement,
within,
} from "coral-framework/testHelpers";
import waitForRTE from "coral-stream/test/helpers/waitForRTE";
import { commenters, settings, stories } from "../../fixtures";
import create from "./create";
@@ -49,16 +49,7 @@ async function createTestRenderer(
},
});
const rte = await waitForElement(
() =>
findParentWithType(
within(testRenderer.root).getByLabelText("Post a comment"),
// We'll use the RTE component here as an exception because the
// jsdom does not support all of what is needed for rendering the
// Rich Text Editor.
RTE
)!
);
const rte = await waitForRTE(testRenderer.root, "Post a comment");
const form = findParentWithType(rte, "form")!;
return {
testRenderer,
@@ -73,19 +64,19 @@ it("validate min", async () => {
const text = "Please enter at least 3 characters.";
act(() => rte.props.onChange({ html: "ab" }));
act(() => rte.props.onChange("ab"));
act(() => form.props.onSubmit());
await act(async () => {
waitForElement(() => within(form).getByText(text));
});
// Reset validation when erasing all content.
act(() => rte.props.onChange({ html: "" }));
act(() => rte.props.onChange(""));
await wait(() => {
expect(within(form).queryByText(text)).toBeNull();
});
act(() => rte.props.onChange({ html: "ab" }));
act(() => rte.props.onChange("ab"));
await wait(() => {
expect(within(form).queryByText(text)).toBeNull();
});
@@ -96,7 +87,7 @@ it("validate max", async () => {
const text = "Please enter at max 10 characters.";
act(() => rte.props.onChange({ html: "abcdefghijklmnopqrst" }));
act(() => rte.props.onChange("abcdefghijklmnopqrst"));
act(() => {
form.props.onSubmit();
});
@@ -105,12 +96,12 @@ it("validate max", async () => {
});
// Reset validation when erasing all content.
act(() => rte.props.onChange({ html: "" }));
act(() => rte.props.onChange(""));
await wait(() => {
expect(within(form).queryByText(text)).toBeNull();
});
act(() => rte.props.onChange({ html: "abcdefghijklmnopqrst" }));
act(() => rte.props.onChange("abcdefghijklmnopqrst"));
await wait(() => {
expect(within(form).queryByText(text)).toBeNull();
});
@@ -119,9 +110,9 @@ it("validate max", async () => {
it("show remaining characters", async () => {
const { rte, form } = await createTestRenderer();
act(() => rte.props.onChange({ html: "abc" }));
act(() => rte.props.onChange("abc"));
within(form).getByText("7 characters remaining");
act(() => rte.props.onChange({ html: "abcdefghijkl" }));
act(() => rte.props.onChange("abcdefghijkl"));
within(form).getByText("-2 characters remaining");
});
@@ -161,10 +152,10 @@ it("update from server upon specific char count error", async () => {
{ muteNetworkErrors: true }
);
act(() => rte.props.onChange({ html: "abc" }));
act(() => rte.props.onChange("abc"));
waitForElement(() => within(form).getByText("7 characters remaining"));
act(() => rte.props.onChange({ html: "abcdefgh" }));
act(() => rte.props.onChange("abcdefgh"));
waitForElement(() => within(form).getByText("2 characters remaining"));
act(() => {
@@ -178,7 +169,7 @@ it("update from server upon specific char count error", async () => {
// Body submit error should be displayed.
await waitForElement(() => within(form).getByText(errorCode));
act(() => rte.props.onChange({ html: "abcde" }));
act(() => rte.props.onChange("abcde"));
await wait(() => {
// Body submit error should disappear when form gets dirty.
expect(within(form).queryByText(errorCode)).toBeNull();
@@ -81,15 +81,15 @@ it("validate min", async () => {
const text = "Please enter at least 3 characters.";
act(() => rte.props.onChange({ html: "ab" }));
act(() => rte.props.onChange("ab"));
act(() => form.props.onSubmit());
within(form).getByText(text);
// Reset validation when erasing all content.
act(() => rte.props.onChange({ html: "" }));
act(() => rte.props.onChange(""));
expect(within(form).queryByText(text)).toBeNull();
act(() => rte.props.onChange({ html: "ab" }));
act(() => rte.props.onChange("ab"));
expect(within(form).queryByText(text)).toBeNull();
});
@@ -98,25 +98,25 @@ it("validate max", async () => {
const text = "Please enter at max 10 characters.";
act(() => rte.props.onChange({ html: "abcdefghijklmnopqrst" }));
act(() => rte.props.onChange("abcdefghijklmnopqrst"));
act(() => form.props.onSubmit());
within(form).getByText(text);
// Reset validation when erasing all content.
act(() => rte.props.onChange({ html: "" }));
act(() => rte.props.onChange(""));
expect(within(form).queryByText(text)).toBeNull();
act(() => rte.props.onChange({ html: "abcdefghijklmnopqrst" }));
act(() => rte.props.onChange("abcdefghijklmnopqrst"));
expect(within(form).queryByText(text)).toBeNull();
});
it("show remaining characters", async () => {
const { rte, form } = await createTestRenderer();
act(() => rte.props.onChange({ html: "abc" }));
act(() => rte.props.onChange("abc"));
waitForElement(() => within(form).getByText("7 characters remaining"));
act(() => rte.props.onChange({ html: "abcdefghijkl" }));
act(() => rte.props.onChange("abcdefghijkl"));
waitForElement(() => within(form).getByText("-2 characters remaining"));
});
@@ -156,10 +156,10 @@ it("update from server upon specific char count error", async () => {
{ muteNetworkErrors: true }
);
act(() => rte.props.onChange({ html: "abc" }));
act(() => rte.props.onChange("abc"));
waitForElement(() => within(form).getByText("7 characters remaining"));
act(() => rte.props.onChange({ html: "abcdefgh" }));
act(() => rte.props.onChange("abcdefgh"));
waitForElement(() => within(form).getByText("2 characters remaining"));
await act(async () => form.props.onSubmit());
@@ -168,7 +168,7 @@ it("update from server upon specific char count error", async () => {
// Body submit error should be displayed.
waitForElement(() => within(form).getByText(errorCode));
act(() => rte.props.onChange({ html: "abcde" }));
act(() => rte.props.onChange("abcde"));
// Body submit error should disappear when form gets dirty.
expect(within(form).queryByText(errorCode)).toBeNull();
@@ -18,7 +18,7 @@ import {
} from "../../fixtures";
import create from "./create";
function createTestRenderer(
async function createTestRenderer(
resolver: any = {},
options: { muteNetworkErrors?: boolean; status?: string } = {}
) {
@@ -84,7 +84,7 @@ afterEach(() => {
});
it("edit a comment", async () => {
const testRenderer = createTestRenderer();
const testRenderer = await createTestRenderer();
const comment = await waitForElement(() =>
within(testRenderer.root).getByTestID(`comment-${commentWithReplies.id}`)
@@ -101,9 +101,9 @@ it("edit a comment", async () => {
act(() =>
testRenderer.root
.findByProps({
inputId: `comments-editCommentForm-rte-${commentWithReplies.id}`,
inputID: `comments-editCommentForm-rte-${commentWithReplies.id}`,
})
.props.onChange({ html: "Edited!" })
.props.onChange("Edited!")
);
act(() => {
@@ -125,7 +125,10 @@ it("edit a comment", async () => {
});
it("edit a comment and handle non-published comment state", async () => {
const testRenderer = createTestRenderer({}, { status: "SYSTEM_WITHHELD" });
const testRenderer = await createTestRenderer(
{},
{ status: "SYSTEM_WITHHELD" }
);
const comment = await waitForElement(() =>
within(testRenderer.root).getByTestID(`comment-${commentWithReplies.id}`)
@@ -137,9 +140,9 @@ it("edit a comment and handle non-published comment state", async () => {
act(() =>
testRenderer.root
.findByProps({
inputId: `comments-editCommentForm-rte-${commentWithReplies.id}`,
inputID: `comments-editCommentForm-rte-${commentWithReplies.id}`,
})
.props.onChange({ html: "Edited!" })
.props.onChange("Edited!")
);
act(() => {
@@ -171,7 +174,7 @@ it("edit a comment and handle non-published comment state", async () => {
});
it("cancel edit", async () => {
const testRenderer = createTestRenderer();
const testRenderer = await createTestRenderer();
const comment = await waitForElement(() =>
within(testRenderer.root).getByTestID(`comment-${commentWithReplies.id}`)
@@ -187,7 +190,7 @@ it("cancel edit", async () => {
});
it("shows expiry message", async () => {
const testRenderer = createTestRenderer();
const testRenderer = await createTestRenderer();
const comment = await waitForElement(() =>
within(testRenderer.root).getByTestID(`comment-${commentWithReplies.id}`)
@@ -210,7 +213,7 @@ it("shows expiry message", async () => {
});
it("edit a comment and handle server error", async () => {
const testRenderer = createTestRenderer(
const testRenderer = await createTestRenderer(
{
Mutation: {
editComment: sinon.stub().callsFake(() => {
@@ -232,9 +235,9 @@ it("edit a comment and handle server error", async () => {
act(() =>
testRenderer.root
.findByProps({
inputId: `comments-editCommentForm-rte-${commentWithReplies.id}`,
inputID: `comments-editCommentForm-rte-${commentWithReplies.id}`,
})
.props.onChange({ html: "Edited!" })
.props.onChange("Edited!")
);
act(() => {
@@ -100,7 +100,7 @@ it("post a comment", async () => {
},
});
rte.props.onChange({ html: "<b>Hello world!</b>" });
rte.props.onChange("<b>Hello world!</b>");
timekeeper.freeze(new Date(baseComment.createdAt));
form.props.onSubmit();
@@ -150,7 +150,7 @@ const postACommentAndHandleNonPublishedComment = async (
},
});
rte.props.onChange({ html: "<b>Hello world!</b>" });
rte.props.onChange("<b>Hello world!</b>");
form.props.onSubmit();
// Test after server response.
@@ -172,7 +172,7 @@ it("post a comment and handle non-visible comment state (dismiss by click)", asy
it("post a comment and handle non-visible comment state (dismiss by typing)", async () =>
await postACommentAndHandleNonPublishedComment((form, rte) => {
rte.props.onChange({ html: "Typing..." });
rte.props.onChange("Typing...");
}));
it("post a comment and handle server error", async () => {
@@ -188,7 +188,7 @@ it("post a comment and handle server error", async () => {
{ muteNetworkErrors: true }
);
rte.props.onChange({ html: "<b>Hello world!</b>" });
rte.props.onChange("<b>Hello world!</b>");
form.props.onSubmit();
// Look for internal error being displayed.
@@ -244,7 +244,7 @@ it("handle moderation nudge error", async () => {
{ muteNetworkErrors: true }
);
rte.props.onChange({ html: "<b>Hello world!</b>" });
rte.props.onChange("<b>Hello world!</b>");
form.props.onSubmit();
// Look for internal error being displayed.
@@ -291,7 +291,7 @@ it("handle disabled commenting error", async () => {
{ muteNetworkErrors: true }
);
rte.props.onChange({ html: "abc" });
rte.props.onChange("abc");
form.props.onSubmit();
await waitForElement(() => within(form).getByText("commenting disabled"));
@@ -319,7 +319,7 @@ it("handle story closed", async () => {
{ muteNetworkErrors: true }
);
rte.props.onChange({ html: "abc" });
rte.props.onChange("abc");
form.props.onSubmit();
// Change the story that we return to be closed.
@@ -98,9 +98,9 @@ it("post a reply", async () => {
act(() =>
testRenderer.root
.findByProps({
inputId: "comments-replyCommentForm-rte-comment-with-deepest-replies-3",
inputID: "comments-replyCommentForm-rte-comment-with-deepest-replies-3",
})
.props.onChange({ html: "<b>Hello world!</b>" })
.props.onChange("<b>Hello world!</b>")
);
timekeeper.freeze(new Date(baseComment.createdAt));
@@ -115,7 +115,7 @@ it("post a reply", async () => {
expect(within(comment).toJSON()).toMatchSnapshot("open reply form");
// Write reply .
act(() => rte.props.onChange({ html: "<b>Hello world!</b>" }));
act(() => rte.props.onChange("<b>Hello world!</b>"));
timekeeper.freeze(new Date(baseComment.createdAt));
act(() => {
@@ -170,7 +170,7 @@ it("post a reply and handle non-visible comment state", async () => {
});
// Write reply .
act(() => rte.props.onChange({ html: "<b>Hello world!</b>" }));
act(() => rte.props.onChange("<b>Hello world!</b>"));
act(() => {
form.props.onSubmit();
});
@@ -199,7 +199,7 @@ it("post a reply and handle server error", async () => {
);
// Write reply .
act(() => rte.props.onChange({ html: "<b>Hello world!</b>" }));
act(() => rte.props.onChange("<b>Hello world!</b>"));
act(() => {
form.props.onSubmit();
@@ -261,7 +261,7 @@ it("handle moderation nudge error", async () => {
{ muteNetworkErrors: true }
);
act(() => rte.props.onChange({ html: "<b>Hello world!</b>" }));
act(() => rte.props.onChange("<b>Hello world!</b>"));
act(() => {
form.props.onSubmit();
});
@@ -302,7 +302,7 @@ it("handle disabled commenting error", async () => {
{ muteNetworkErrors: true }
);
act(() => rte.props.onChange({ html: "abc" }));
act(() => rte.props.onChange("abc"));
act(() => {
form.props.onSubmit();
});
@@ -342,7 +342,7 @@ it("handle story closed error", async () => {
{ muteNetworkErrors: true }
);
rte.props.onChange({ html: "abc" });
rte.props.onChange("abc");
form.props.onSubmit();
// Change the story that we return to be closed.
@@ -7,7 +7,7 @@ import { act, wait, waitForElement, within } from "coral-framework/testHelpers";
import { commenters, settings, stories } from "../../fixtures";
import create from "./create";
function createTestRenderer(
async function createTestRenderer(
resolver: any = {},
options: { muteNetworkErrors?: boolean } = {}
) {
@@ -72,7 +72,7 @@ function createTestRenderer(
it("render popup", async () => {
const commentID = stories[0].comments.edges[0].node.id;
const { testRenderer } = createTestRenderer();
const { testRenderer } = await createTestRenderer();
const comment = await waitForElement(() =>
within(testRenderer.root).getByTestID(`comment-${commentID}`)
);
@@ -88,7 +88,7 @@ it("render popup", async () => {
it("close popup", async () => {
const commentID = stories[0].comments.edges[0].node.id;
const { testRenderer } = createTestRenderer();
const { testRenderer } = await createTestRenderer();
const comment = await waitForElement(() =>
within(testRenderer.root).getByTestID(`comment-${commentID}`)
);
@@ -109,7 +109,7 @@ it("close popup", async () => {
it("render popup expanded", async () => {
const commentID = stories[0].comments.edges[0].node.id;
const { testRenderer } = createTestRenderer();
const { testRenderer } = await createTestRenderer();
const comment = await waitForElement(() =>
within(testRenderer.root).getByTestID(`comment-${commentID}`)
);
@@ -135,7 +135,7 @@ it("render popup expanded", async () => {
it("report comment as offensive", async () => {
const commentID = stories[0].comments.edges[0].node.id;
const { testRenderer, resolvers } = createTestRenderer();
const { testRenderer, resolvers } = await createTestRenderer();
const comment = await waitForElement(() =>
within(testRenderer.root).getByTestID(`comment-${commentID}`)
);
@@ -186,7 +186,7 @@ it("report comment as offensive", async () => {
it("dont agree with comment", async () => {
const commentID = stories[0].comments.edges[0].node.id;
const { testRenderer, resolvers } = createTestRenderer();
const { testRenderer, resolvers } = await createTestRenderer();
const comment = await waitForElement(() =>
within(testRenderer.root).getByTestID(`comment-${commentID}`)
);
@@ -244,7 +244,7 @@ it("dont agree with comment", async () => {
it("report comment as offensive and handle server error", async () => {
const commentID = stories[0].comments.edges[0].node.id;
const { testRenderer } = createTestRenderer(
const { testRenderer } = await createTestRenderer(
{
Mutation: {
createCommentFlag: sinon.stub().callsFake(() => {
@@ -0,0 +1,148 @@
import sinon from "sinon";
import { GQLRTEConfiguration } from "coral-framework/schema";
import { act, waitForElement, within } from "coral-framework/testHelpers";
import waitForRTE from "coral-stream/test/helpers/waitForRTE";
import { commenters, settings, stories } from "../../fixtures";
import create from "./create";
async function createTestRenderer(
resolver: any,
rteConfig: GQLRTEConfiguration,
options: { muteNetworkErrors?: boolean } = {}
) {
const resolvers = {
...resolver,
Query: {
settings: sinon.stub().returns({
...settings,
rte: rteConfig,
}),
viewer: sinon.stub().returns(commenters[0]),
stream: sinon.stub().callsFake((_: any, variables: any) => {
expectAndFail(variables.id).toBe(stories[0].id);
return stories[0];
}),
...resolver.Query,
},
};
const { testRenderer, context } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
muteNetworkErrors: options.muteNetworkErrors,
resolvers,
initLocalState: (localRecord) => {
localRecord.setValue(stories[0].id, "storyID");
},
});
const tabPane = await waitForElement(() =>
within(testRenderer.root).getByTestID("current-tab-pane")
);
const rte = await waitForRTE(tabPane, "Post a comment");
return {
testRenderer,
context,
tabPane,
rte,
};
}
it("disabled rte formatting", async () => {
await act(async () => {
const { rte } = await createTestRenderer(
{},
{
enabled: false,
spoiler: true,
strikethrough: true,
}
);
expect(within(rte).queryByTitle("Bold")).toBeNull();
expect(within(rte).queryByTitle("Italic")).toBeNull();
expect(within(rte).queryByTitle("Blockquote")).toBeNull();
expect(within(rte).queryByTitle("Bulleted List")).toBeNull();
expect(within(rte).queryByTitle("Strikethrough")).toBeNull();
expect(within(rte).queryByTitle("Spoiler")).toBeNull();
});
});
it("enable basic rte formatting", async () => {
await act(async () => {
const { rte } = await createTestRenderer(
{},
{
enabled: true,
spoiler: false,
strikethrough: false,
}
);
expect(within(rte).queryByTitle("Bold")).not.toBeNull();
expect(within(rte).queryByTitle("Italic")).not.toBeNull();
expect(within(rte).queryByTitle("Blockquote")).not.toBeNull();
expect(within(rte).queryByTitle("Bulleted List")).not.toBeNull();
expect(within(rte).queryByTitle("Strikethrough")).toBeNull();
expect(within(rte).queryByTitle("Spoiler")).toBeNull();
});
});
it("enable strike formatting", async () => {
await act(async () => {
const { rte } = await createTestRenderer(
{},
{
enabled: true,
strikethrough: true,
spoiler: false,
}
);
expect(within(rte).queryByTitle("Bold")).not.toBeNull();
expect(within(rte).queryByTitle("Italic")).not.toBeNull();
expect(within(rte).queryByTitle("Blockquote")).not.toBeNull();
expect(within(rte).queryByTitle("Bulleted List")).not.toBeNull();
expect(within(rte).queryByTitle("Strikethrough")).not.toBeNull();
expect(within(rte).queryByTitle("Spoiler")).toBeNull();
});
});
it("enable spoiler formatting", async () => {
await act(async () => {
const { rte } = await createTestRenderer(
{},
{
enabled: true,
strikethrough: false,
spoiler: true,
}
);
expect(within(rte).queryByTitle("Bold")).not.toBeNull();
expect(within(rte).queryByTitle("Italic")).not.toBeNull();
expect(within(rte).queryByTitle("Blockquote")).not.toBeNull();
expect(within(rte).queryByTitle("Bulleted List")).not.toBeNull();
expect(within(rte).queryByTitle("Strikethrough")).toBeNull();
expect(within(rte).queryByText("Spoiler")).not.toBeNull();
});
});
it("enable all formatting", async () => {
await act(async () => {
const { rte } = await createTestRenderer(
{},
{
enabled: true,
strikethrough: true,
spoiler: true,
}
);
expect(within(rte).queryByTitle("Bold")).not.toBeNull();
expect(within(rte).queryByTitle("Italic")).not.toBeNull();
expect(within(rte).queryByTitle("Blockquote")).not.toBeNull();
expect(within(rte).queryByTitle("Bulleted List")).not.toBeNull();
expect(within(rte).queryByTitle("Strikethrough")).not.toBeNull();
expect(within(rte).queryByText("Spoiler")).not.toBeNull();
});
});
+5
View File
@@ -110,6 +110,11 @@ export const settings = createFixture<GQLSettings>({
},
multisite: false,
featureFlags: [],
rte: {
enabled: true,
strikethrough: false,
spoiler: false,
},
});
export const site = createFixture<GQLSite>({
@@ -0,0 +1,30 @@
import RTE from "@coralproject/rte";
import {
act,
findParentWithType,
waitForElement,
within,
} from "coral-framework/testHelpers";
import { ReactTestInstance } from "react-test-renderer";
/**
* waitForRTE returns an promise that resolves to the instance of `RTE`.
*
* @param instance The instance to look within
* @param label The label of the RTE
* @returns Promise of RTE TestInstance
*/
export default function waitForRTE(instance: ReactTestInstance, label: string) {
return act(() =>
waitForElement(
() =>
findParentWithType(
within(instance).getByLabelText(label),
// We'll use the RTE component here as an exception because the
// jsdom does not support all of what is needed for rendering the
// Rich Text Editor.
RTE
)!
)
);
}
@@ -0,0 +1,11 @@
import { ReactTestRenderer } from "react-test-renderer";
import waitForRTE from "./waitForRTE";
export default async function waitForCommentsTabInit(
testRenderer: ReactTestRenderer
) {
// Wait for RTE initialization.
await waitForRTE(testRenderer.root, "Post a comment");
return;
}
+4 -35
View File
@@ -1,36 +1,5 @@
import { JSDOM } from "jsdom";
// JSDOM is already injected from Jest.
declare const global: any;
const jsdom = new JSDOM("<!doctype html><html><body></body></html>");
const { window } = jsdom;
// tiny shim for getSelection for the RTE.
function copyProps(src: any, target: any) {
const props = Object.getOwnPropertyNames(src)
.filter((prop) => typeof target[prop] === "undefined")
.reduce(
(result, prop) => ({
...result,
[prop]: Object.getOwnPropertyDescriptor(src, prop),
}),
{}
);
Object.defineProperties(target, props);
}
global.window = window;
global.document = (window as any).document;
global.navigator = {
userAgent: "node.js",
};
copyProps(window, global);
global.window.getSelection = () =>
({
addRange() {},
removeAllRanges() {},
} as any);
global.window.resizeTo = () => {};
// We replace `resizeTo` JSDOM implentation - which just returns an error -
// with a noop.
window.resizeTo = () => {};
+2 -2
View File
@@ -5,12 +5,12 @@ jest.mock("react-transition-group", () => ({
}));
jest.mock("react-dom", () => ({
...require.requireActual("react-dom"),
...jest.requireActual("react-dom"),
createPortal: (node: any) => node,
}));
jest.mock("popper.js", () => {
const PopperJS = require.requireActual("popper.js");
const PopperJS = jest.requireActual("popper.js");
return class Popper {
public static placements = PopperJS.placements;
+31 -14
View File
@@ -13,6 +13,7 @@ const failPatterns: PatternMap = {
"You called act(async () => ...) without await",
"OptimisticResponse warnings":
"`optimisticResponse` to match structure of server response",
"Missing translation": "en-US translation for key",
};
/**
@@ -32,21 +33,37 @@ const originalError = global.console.error;
const originalWarn = global.console.warn;
const originalLog = global.console.log;
function argToString(arg: any): string {
if (typeof arg === "string") {
return arg;
}
if (arg.toString !== undefined) {
return arg.toString();
}
try {
return JSON.stringify(arg);
} catch {
return "";
}
}
function getMatchingPatterns(patterns: PatternMap, args: any[]) {
const str = args
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
.join(" ");
const matchedPatterns: string[] = [];
Object.keys(patterns).forEach((k) => {
const matching =
typeof patterns[k] === "string"
? str.includes(patterns[k] as string)
: str.match(patterns[k]);
if (matching !== false && matching !== null) {
matchedPatterns.push(k);
}
});
return matchedPatterns;
try {
const str = args.map((a) => argToString(a)).join(" ");
const matchedPatterns: string[] = [];
Object.keys(patterns).forEach((k) => {
const matching =
typeof patterns[k] === "string"
? str.includes(patterns[k] as string)
: str.match(patterns[k]);
if (matching !== false && matching !== null) {
matchedPatterns.push(k);
}
});
return matchedPatterns;
} catch {
return [];
}
}
function createMockImplementation(originalFunction: (...args: any[]) => void) {
@@ -1,3 +1,4 @@
import cn from "classnames";
import React, { FunctionComponent, LabelHTMLAttributes } from "react";
import styles from "./Label.css";
@@ -7,10 +8,15 @@ interface Props extends LabelHTMLAttributes<any> {
component?: "legend" | "p";
}
const Label: FunctionComponent<Props> = ({ children, component, ...rest }) => {
const Label: FunctionComponent<Props> = ({
children,
component,
className,
...rest
}) => {
const Component = component || "label";
return (
<Component {...rest} className={styles.root}>
<Component {...rest} className={cn(styles.root, className)}>
{children}
</Component>
);
+5
View File
@@ -67,3 +67,8 @@ export const DEFAULT_SESSION_DURATION = 90 * TIME.DAY;
* same comment repeatedly.
*/
export const COMMENT_REPEAT_POST_DURATION = 6 * TIME.MINUTE;
/**
* SPOILER_CLASSNAME is the classname that is attached to spoilers.
*/
export const SPOILER_CLASSNAME = "coral-rte-spoiler";
@@ -0,0 +1,154 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`allows anchor links 1`] = `
<body>
<a
href="http://test.com"
rel="noopener noreferrer"
target="_blank"
>
http://test.com
</a>
</body>
`;
exports[`allows anchor tags and counts them correctly 1`] = `
"
<div>
<a href=\\"https://mozilla.org/\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">https://mozilla.org/</a>
<a href=\\"https://mozilla.org/\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">https://mozilla.org/</a>
</div>
"
`;
exports[`allows blockquote 1`] = `
<body>
<blockquote>
bulleted list
</blockquote>
</body>
`;
exports[`allows bolded tags 1`] = `
<body>
A
<b>
bolded comment!
</b>
</body>
`;
exports[`allows bolded tags 2`] = `
<body>
A
<strong>
bolded comment!
</strong>
</body>
`;
exports[`allows bulleted list 1`] = `
<body>
<ul>
<li>
bulleted list
</li>
</ul>
</body>
`;
exports[`allows italic tags 1`] = `
<body>
<em>
italic!
</em>
</body>
`;
exports[`allows italic tags 2`] = `
<body>
<i>
italic!
</i>
</body>
`;
exports[`allows mailto links 1`] = `
<body>
<a
href="mailto:email@example.com"
rel="noopener noreferrer"
target="_blank"
>
email@example.com
</a>
</body>
`;
exports[`allows spoiler 1`] = `
<body>
<span
class="coral-rte-spoiler"
>
Spoiler
</span>
</body>
`;
exports[`allows strikthrough 1`] = `
<body>
<s>
strikethrough
</s>
</body>
`;
exports[`sanitizes out attributes not allowed 1`] = `
<body>
<div>
Test
</div>
</body>
`;
exports[`sanitizes out tags not allowed 1`] = `<body />`;
exports[`sanitizes out tags not allowed 2`] = `<body />`;
exports[`sanitizes without features enabled 1`] = `
<body>
<div>
Strong
Strong
Italic
Italic
Blockquote
Strike
Spoiler
</div>
</body>
`;
@@ -0,0 +1,26 @@
import getHTMLPlainText from "./getHTMLPlainText";
const example1 =
'<p>Meat & Fish<br></p><ul><li>Skinless white meat<br></li><li>Lean cuts of red meat<br></li><li><div>Oily fish<br></div><ul><li>Tuna<br></li><li>Salmon<br></li><li>Mackerel<br></li></ul></li><li>Luncheon meat<br></li></ul><div><b>Bold</b><br></div><div><br></div><div>Italic<br></div><div><br></div><blockquote><div>Blockquote</div></blockquote><div><span class="coral-rte-spoiler">Spoiler</span></div>';
it("transforms HTML to text", () => {
expect(getHTMLPlainText(example1)).toMatchInlineSnapshot(`
"Meat & Fish
Skinless white meat
Lean cuts of red meat
Oily fish
Tuna
Salmon
Mackerel
Luncheon meat
Bold
Italic
Blockquote
Spoiler"
`);
});
@@ -0,0 +1,24 @@
/**
* getHTMLPlainText returns text representation of html.
*
* @param html string
*/
export default function getHTMLPlainText(html: string): string {
const htmlWithNewLine = html.replace(
/((<\/(ul|li|blockquote)>)|(<\/(div|p)>\s*(<[^/]))|(<br>(?!\s*<\/(div|p|li|blockquote))))/g,
"\n$1"
);
let textContent: string;
if (process.env.WEBPACK !== "true") {
// textContent is not fully implemented in JSDOM, so we use `striptags` inste ad.
// eslint-disable-next-line @typescript-eslint/no-var-requires
textContent = require("striptags")(htmlWithNewLine);
} else {
const divElement = document.createElement("div");
divElement.innerHTML = htmlWithNewLine;
textContent = divElement.textContent || "";
}
return textContent.trimRight();
}
@@ -0,0 +1,24 @@
import removeTrailingEmptyLines from "./removeTrailingEmptyLines";
function createNodeWithHTML(html: string) {
const el = document.createElement("div");
el.innerHTML = html;
return el;
}
it("trims HTML", () => {
const examples = [
createNodeWithHTML(
"<div>a</div><div><br></div><div><br></div><div><br></div><div> <br></div>"
),
createNodeWithHTML(
"<div>a<br></div><div><br></div><div><b>b</b><br></div>"
),
];
examples.forEach((e) => removeTrailingEmptyLines(e));
expect(examples[0].innerHTML).toMatchInlineSnapshot(`"<div>a</div>"`);
expect(examples[1].innerHTML).toMatchInlineSnapshot(
`"<div>a<br></div><div><br></div><div><b>b</b><br></div>"`
);
});
@@ -0,0 +1,20 @@
/**
* removeTrailingEmptyLines removes empty lines from HTMLElement.
*/
export default function removeTrailingEmptyLines(element: HTMLElement) {
while (element.lastElementChild) {
let content: string;
if (process.env.WEBPACK === "true") {
content = element.lastElementChild?.textContent || "";
} else {
// textContent is not fully implemented in JSDOM, so we use `innerHTML` and `striptags` instead.
// eslint-disable-next-line @typescript-eslint/no-var-requires
content = require("striptags")(element.lastElementChild.innerHTML);
}
if (content.trim() === "") {
element.removeChild(element.lastElementChild);
continue;
}
break;
}
}
+133
View File
@@ -0,0 +1,133 @@
import { JSDOM } from "jsdom";
import { SPOILER_CLASSNAME } from "coral-common/constants";
import { ALL_FEATURES, createSanitize } from "./sanitize";
const window = new JSDOM("", {}).window;
it("sanitizes out tags not allowed", () => {
const sanitize = createSanitize(window as any, {
features: ALL_FEATURES,
});
expect(
sanitize("<script type=\"text/javascript\">alert('ok');</script>")
).toMatchSnapshot();
expect(sanitize("<script src=malicious-code.js></script>")).toMatchSnapshot();
});
it("sanitizes out attributes not allowed", () => {
const sanitize = createSanitize(window as any, {
features: ALL_FEATURES,
});
expect(sanitize('<div id="test">Test</div>')).toMatchSnapshot();
});
it("sanitizes without features enabled", () => {
const sanitize = createSanitize(window as any);
expect(
sanitize(
`
<div>
<ul>
<li><b>Strong<b></li>
<li><strong>Strong<strong></li>
<li><i>Italic<i></li>
<li><em>Italic<em></li>
</ul>
<blockquote>Blockquote</blockquote>
<s>Strike</s>
<span class="${SPOILER_CLASSNAME}">Spoiler</span>
</div>
`
)
).toMatchSnapshot();
});
it("allows anchor links", () => {
const sanitize = createSanitize(window as any);
expect(
sanitize('<a href="http://test.com">This is a link</a>')
).toMatchSnapshot();
});
it("allows mailto links", () => {
const sanitize = createSanitize(window as any);
expect(
sanitize('<a href="mailto:email@example.com">email@example.com</a>')
).toMatchSnapshot();
});
it("allows anchor tags and counts them correctly", () => {
const sanitize = createSanitize(window as any);
const el = sanitize(
`
<div>
<a href="https://mozilla.org/">Mozilla</a>
<a href="https://mozilla.org/">Mozilla</a>
</div>
`
);
expect(el.innerHTML).toMatchSnapshot();
expect(el.getElementsByTagName("a").length).toEqual(2);
});
it("allows bolded tags", () => {
const sanitize = createSanitize(window as any, {
features: {
bold: true,
},
});
expect(sanitize("A <b>bolded comment!</b>")).toMatchSnapshot();
expect(sanitize("A <strong>bolded comment!</strong>")).toMatchSnapshot();
});
it("allows italic tags", () => {
const sanitize = createSanitize(window as any, {
features: {
italic: true,
},
});
expect(sanitize("<em>italic!</em>")).toMatchSnapshot();
expect(sanitize("<i>italic!</i>")).toMatchSnapshot();
});
it("allows bulleted list", () => {
const sanitize = createSanitize(window as any, {
features: {
bulletList: true,
},
});
expect(sanitize("<ul><li>bulleted list</li></ul>")).toMatchSnapshot();
});
it("allows blockquote", () => {
const sanitize = createSanitize(window as any, {
features: {
blockquote: true,
},
});
expect(sanitize("<blockquote>bulleted list</blockquote>")).toMatchSnapshot();
});
it("allows strikthrough", () => {
const sanitize = createSanitize(window as any, {
features: {
strikethrough: true,
},
});
expect(sanitize("<s>strikethrough</s>")).toMatchSnapshot();
});
it("allows spoiler", () => {
const sanitize = createSanitize(window as any, {
features: {
spoiler: true,
},
});
expect(
sanitize(`<span class="${SPOILER_CLASSNAME}">Spoiler</span>`)
).toMatchSnapshot();
});
+184
View File
@@ -0,0 +1,184 @@
import createDOMPurify, { DOMPurifyI } from "dompurify";
import { SPOILER_CLASSNAME } from "coral-common/constants";
// TODO: Reaching directly into coral-framework for the types. Maybe having
// types in coral-common instead? 🤔
import { GQLRTEConfiguration } from "../../client/framework/schema/__generated__/types";
export interface RTEFeatures {
bold?: boolean;
italic?: boolean;
blockquote?: boolean;
bulletList?: boolean;
strikethrough?: boolean;
spoiler?: boolean;
}
/**
* ALL_FEATURES is a predefined map of RTEFeatures with all
* features turned on
*/
export const ALL_FEATURES: RTEFeatures = {
bold: true,
italic: true,
blockquote: true,
bulletList: true,
strikethrough: true,
spoiler: true,
};
const MAILTO_PROTOCOL = "mailto:";
/**
* convertGQLRTEConfigToRTEFeatures turns the
* RTE configuration from the GraphQL Schema to
* RTEFeatures.
*/
export function convertGQLRTEConfigToRTEFeatures(
config: Partial<GQLRTEConfiguration>
): RTEFeatures {
return {
bold: config.enabled,
italic: config.enabled,
blockquote: config.enabled,
bulletList: config.enabled,
strikethrough: config.enabled && config.strikethrough,
spoiler: config.enabled && config.spoiler,
};
}
/**
* Ensure that each anchor tag has a "target" and "rel" attributes set, and
* strip the "href" attribute from all non-anchor tags.
*/
const sanitizeAnchor = (node: Element) => {
if (node.nodeName === "A") {
// Ensure we wrap all the links with the target + rel set.
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
// Ensure that all the links have the same link as they do text.
let href = node.getAttribute("href");
if (href) {
if (node.textContent !== href) {
// remove "mailto:" prefix from link text
const url = new URL(href);
if (url.protocol === MAILTO_PROTOCOL) {
href = href.replace(url.protocol, "");
}
}
node.textContent = href;
}
}
};
/**
* Further restrict the use of attributes.
*/
const sanitizeAttributes = (features: RTEFeatures, node: Element) => {
// Only achor tags can have the `href` attribute.
if (node.nodeName !== "A" && node.getAttribute("href")) {
node.removeAttribute("href");
}
if (features.spoiler) {
// Only allow <span class="SPOILER_CLASSNAME"> as our spoiler tag.
if (
node.nodeName === "SPAN" &&
node.classList.contains(SPOILER_CLASSNAME)
) {
// Remove other classes.
node.className = SPOILER_CLASSNAME;
} else {
node.removeAttribute("class");
}
}
};
function createPurifyConfig(features: RTEFeatures) {
const ALLOWED_TAGS = ["a", "br", "div", "p"];
const ALLOWED_ATTR = [
// Allow href tags for anchor tags.
"href",
];
if (features.bold) {
ALLOWED_TAGS.push("b", "strong");
}
if (features.italic) {
ALLOWED_TAGS.push("i", "em");
}
if (features.blockquote) {
ALLOWED_TAGS.push("blockquote");
}
if (features.bulletList) {
ALLOWED_TAGS.push("ul", "li");
}
if (features.strikethrough) {
ALLOWED_TAGS.push("s");
}
if (features.spoiler) {
ALLOWED_TAGS.push("span");
ALLOWED_ATTR.push("class");
}
return {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
// `ALLOW_ARIA_ATTR` not typed as for v2.0.1.
ALLOW_ARIA_ATTR: false,
};
}
export interface SanitizeOptions {
/** Allow overriding parts of the config */
config?: any;
/** normalize makes sure that neighboring text nodes are merged */
normalize?: boolean;
/** modify allows accessing the purify instance to e.g. add hooks */
modify?: (purify: DOMPurifyI) => void;
/** enable individual features. If not set, all are disabled */
features?: RTEFeatures;
}
export type Sanitize = (source: Node | string) => HTMLElement;
export function createSanitize(
window: Window,
options?: SanitizeOptions
): Sanitize {
// Initializing JSDOM and DOMPurify
const purify = createDOMPurify(window);
const features = options?.features || {};
// Setting our DOMPurify config.
purify.setConfig({
...createPurifyConfig(features),
// Always return the DOM to the caller of sanitize.
RETURN_DOM: true,
...options?.config,
});
purify.addHook(
"afterSanitizeAttributes",
sanitizeAttributes.bind(null, features)
);
purify.addHook("afterSanitizeAttributes", sanitizeAnchor);
if (options?.normalize) {
purify.addHook("afterSanitizeElements", (n) => {
if (
n.nodeType === Node.TEXT_NODE &&
n.previousSibling?.nodeType === Node.TEXT_NODE
) {
// Merge text node sublings together.
// eslint-disable-next-line no-unused-expressions
n.parentNode?.normalize();
}
});
}
if (options?.modify) {
options.modify(purify);
}
return purify.sanitize.bind(purify);
}
@@ -1,45 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`allows anchor links 1`] = `
Object {
"body": "<a href=\\"http://test.com\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">http://test.com</a>",
"linkCount": 1,
}
`;
exports[`allows anchor tags and counts them correctly 1`] = `
"
<div>
<a href=\\"https://mozilla.org/\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">https://mozilla.org/</a>
<a href=\\"https://mozilla.org/\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">https://mozilla.org/</a>
</div>
"
`;
exports[`allows mailto links 1`] = `
Object {
"body": "<a href=\\"mailto:email@example.com\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">email@example.com</a>",
"linkCount": 1,
}
`;
exports[`sanitizes out attributes not allowed 1`] = `
Object {
"body": "<div>Test</div>",
"linkCount": 0,
}
`;
exports[`sanitizes out tags not allowed 1`] = `
Object {
"body": "",
"linkCount": 0,
}
`;
exports[`sanitizes out tags not allowed 2`] = `
Object {
"body": "",
"linkCount": 0,
}
`;
-64
View File
@@ -1,64 +0,0 @@
import { JSDOM } from "jsdom";
import { createPurify, sanitizeCommentBody } from "./purify";
const window = new JSDOM("", {}).window;
const DOMPurify = createPurify(window as any);
it("sanitizes out tags not allowed", () => {
expect(
sanitizeCommentBody(
DOMPurify,
"<script type=\"text/javascript\">alert('ok');</script>"
)
).toMatchSnapshot();
expect(
sanitizeCommentBody(DOMPurify, "<script src=malicious-code.js></script>")
).toMatchSnapshot();
});
it("sanitizes out attributes not allowed", () => {
expect(
sanitizeCommentBody(DOMPurify, '<div id="test">Test</div>')
).toMatchSnapshot();
});
it("allows anchor links", () => {
expect(
sanitizeCommentBody(
DOMPurify,
'<a href="http://test.com">This is a link</a>'
)
).toMatchSnapshot();
});
it("allows mailto links", () => {
expect(
sanitizeCommentBody(
DOMPurify,
'<a href="mailto:email@example.com">email@example.com</a>'
)
).toMatchSnapshot();
});
it("allows anchor tags and counts them correctly", () => {
const { body, linkCount } = sanitizeCommentBody(
DOMPurify,
`
<div>
<a href="https://mozilla.org/">Mozilla</a>
<a href="https://mozilla.org/">Mozilla</a>
</div>
`
);
expect(body).toMatchSnapshot();
expect(linkCount).toEqual(2);
});
it("allows bolded tags", () => {
const input = "A <b>bolded comment!</b>";
const { body } = sanitizeCommentBody(DOMPurify, input);
expect(body).toEqual(input);
});
-62
View File
@@ -1,62 +0,0 @@
import createDOMPurify from "dompurify";
type DOMPurify = ReturnType<typeof createDOMPurify>;
const MAILTO_PROTOCOL = "mailto:";
export function createPurify(window: Window, returnDOM = true) {
// Initializing JSDOM and DOMPurify
const purify = createDOMPurify(window);
// Setting our DOMPurify config.
purify.setConfig({
// Only forward anchor tags, bold, italics, blockquote, breaks, divs, and
// spans.
ALLOWED_TAGS: ["a", "b", "i", "blockquote", "br", "div", "span"],
// Only allow href tags for anchor tags.
ALLOWED_ATTR: ["href"],
// Always return the DOM to the caller of sanitize.
RETURN_DOM: returnDOM,
});
// Ensure that each anchor tag has a "target" and "rel" attributes set, and
// strip the "href" attribute from all non-anchor tags.
purify.addHook("afterSanitizeAttributes", (node) => {
if (node.nodeName === "A") {
// Ensure we wrap all the links with the target + rel set.
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
// Ensure that all the links have the same link as they do text.
let href = node.getAttribute("href");
if (href) {
if (node.textContent !== href) {
// remove "mailto:" prefix from link text
const url = new URL(href);
if (url.protocol === MAILTO_PROTOCOL) {
href = href.replace(url.protocol, "");
}
}
node.textContent = href;
}
} else {
// The only tag that's allowed attributes is the "A" tag.
node.removeAttribute("href");
}
});
return purify;
}
export function sanitizeCommentBody(purify: DOMPurify, source: string) {
// Sanitize and return the HTMLBodyElement for the parsed source.
const sanitized = purify.sanitize(source, { RETURN_DOM: true });
// Count the total number of anchor links in the sanitized output, this is the
// number of links.
const linkCount = sanitized.getElementsByTagName("a").length;
return {
body: sanitized.innerHTML,
linkCount,
};
}
@@ -1,5 +1,4 @@
import striptags from "striptags";
import getHTMLPlainText from "coral-common/helpers/getHTMLPlainText";
import { reconstructTenantURL } from "coral-server/app/url";
import { sendToPerspective } from "coral-server/services/perspective";
@@ -94,7 +93,7 @@ export class PerspectiveCoralEventListener
operation: "comments:suggestscore",
locale: ctx.tenant.locale,
body: {
text: striptags(revision.body),
text: getHTMLPlainText(revision.body),
commentID: comment.id,
commentParentID: comment.parentID,
commentStatus: status,
@@ -1,5 +1,4 @@
import striptags from "striptags";
import getHTMLPlainText from "coral-common/helpers/getHTMLPlainText";
import { reconstructTenantURL } from "coral-server/app/url";
import GraphContext from "coral-server/graph/context";
import { Comment, getLatestRevision } from "coral-server/models/comment";
@@ -94,8 +93,7 @@ export default class SlackPublishEvent {
);
const commentLink = getURLWithCommentID(this.story.url, this.comment.id);
// Replace HTML link breaks with newlines.
const body = striptags(getLatestRevision(this.comment).body);
const body = getHTMLPlainText(getLatestRevision(this.comment).body);
return [
{
type: "section",
@@ -1,3 +1,4 @@
import { defaultRTEConfiguration } from "coral-server/models/settings";
import {
retrieveAnnouncementIfEnabled,
Tenant,
@@ -29,4 +30,5 @@ export const Settings: GQLSettingsTypeResolver<Tenant> = {
return sites.edges.length > 1;
},
webhookEvents: () => Object.values(GQLWEBHOOK_EVENT_NAME),
rte: ({ rte = defaultRTEConfiguration }) => rte,
};
@@ -1508,6 +1508,22 @@ type Site {
createdAt: Time!
}
type RTEConfiguration {
"""
enabled when true turns on basic RTE features including
bold, italic, quote, and bullet list.
"""
enabled: Boolean!
"""
strikethrough when true turns on the strikethrough feature.
"""
strikethrough: Boolean!
"""
spoiler when true turns on the spoiler feature.
"""
spoiler: Boolean!
}
"""
Settings stores the global settings for a given Tenant.
"""
@@ -1669,6 +1685,11 @@ type Settings {
multisite is whether multiple sites exist for this tenant.
"""
multisite: Boolean!
"""
rte is the configuration of the Rich-Text-Editor.
"""
rte: RTEConfiguration!
}
################################################################################
@@ -4138,6 +4159,26 @@ input NewCommentersConfigurationInput {
approvedCommentsThreshold: Int
}
"""
RTEConfigurationInput specifies the configuration for the rte.
"""
input RTEConfigurationInput {
"""
enabled when true turns on basic RTE features including
bold, italic, quote, and bullet list.
"""
enabled: Boolean!
"""
strikethrough when true turns on the strikethrough feature.
"""
strikethrough: Boolean!
"""
spoiler when true turns on the spoiler feature.
"""
spoiler: Boolean!
}
"""
SettingsInput is the partial type of the Settings type for performing mutations.
"""
@@ -4257,8 +4298,14 @@ input SettingsInput {
newCommenters is the configuration for how new commenters comments are treated.
"""
newCommenters: NewCommentersConfigurationInput
"""
rte is the configuration of the Rich-Text-Editor.
"""
rte: RTEConfigurationInput
}
"""
UpdateSettingsInput provides the input for the updateSettings Mutation.
"""
@@ -11,6 +11,7 @@ import {
GQLMODERATION_MODE,
GQLOIDCAuthIntegration,
GQLPerspectiveExternalIntegration,
GQLRTEConfiguration,
GQLSettings,
} from "coral-server/graph/schema/__generated__/types";
@@ -221,4 +222,12 @@ export type Settings = GlobalModerationSettings &
* newCommenters is the configuration for how new commenters comments are treated.
*/
newCommenters: NewCommentersConfiguration;
rte?: GQLRTEConfiguration;
};
export const defaultRTEConfiguration: GQLRTEConfiguration = {
enabled: true,
spoiler: false,
strikethrough: false,
};
+2
View File
@@ -10,6 +10,7 @@ import { DeepPartial, Sub } from "coral-common/types";
import { isBeforeDate } from "coral-common/utils";
import { dotize } from "coral-common/utils/dotize";
import {
defaultRTEConfiguration,
generateSigningSecret,
Settings,
SigningSecretResource,
@@ -263,6 +264,7 @@ export async function createTenant(
slack: {
channels: [],
},
rte: defaultRTEConfiguration,
};
// Create the new Tenant by merging it together with the defaults.
@@ -11,19 +11,27 @@ import {
export const commentLength: IntermediateModerationPhase = ({
tenant,
htmlStripped,
bodyText,
}): IntermediatePhaseResult | void => {
const length = htmlStripped.trim().length;
const length = bodyText.length;
let min: number | null = null;
let max: number | null = null;
if (tenant.charCount && tenant.charCount.enabled) {
if (!isNil(tenant.charCount.min)) {
if (length < tenant.charCount.min) {
throw new CommentBodyTooShortError(tenant.charCount.min);
}
min = tenant.charCount.min;
}
if (!isNil(tenant.charCount.max)) {
if (length > tenant.charCount.max) {
throw new CommentBodyExceedsMaxLengthError(tenant.charCount.max);
}
max = tenant.charCount.max;
}
}
if (!min) {
// Comment body should have at least 1 character.
min = 1;
}
if (length < min) {
throw new CommentBodyTooShortError(min);
}
if (max && length > max) {
throw new CommentBodyExceedsMaxLengthError(max);
}
};
@@ -163,7 +163,7 @@ async function processPhase(
mongo,
action,
comment,
htmlStripped,
bodyText,
author,
tenant,
story,
@@ -179,9 +179,7 @@ async function processPhase(
body:
// Depending on the selected format, the comment body could be in an
// HTML or HTML stripped format.
phase.format === GQLCOMMENT_BODY_FORMAT.HTML
? comment.body
: htmlStripped,
phase.format === GQLCOMMENT_BODY_FORMAT.HTML ? comment.body : bodyText,
// We're casting this to a `string | null` here because it's more
// actionable to get a `null` rather than an undefined value in a
// request.
@@ -1,19 +1,76 @@
import { JSDOM } from "jsdom";
import { memoize } from "lodash";
import { createPurify, sanitizeCommentBody } from "coral-common/utils/purify";
import removeTrailingEmptyLines from "coral-common/helpers/removeTrailingEmptyLines";
import {
convertGQLRTEConfigToRTEFeatures,
createSanitize,
} from "coral-common/helpers/sanitize";
import { defaultRTEConfiguration } from "coral-server/models/settings";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "coral-server/services/comments/pipeline";
import { GQLRTEConfiguration } from "coral-server/graph/schema/__generated__/types";
// Initializing JSDOM and DOMPurify
const window = new JSDOM("", {}).window;
const DOMPurify = createPurify(window as any);
/**
* config2CacheKey returns a stable cache key from a `GQLRTEConfiguration` object.
*/
function config2CacheKey(config: GQLRTEConfiguration) {
let ret = "";
if (config.enabled) {
ret += "e";
}
if (config.spoiler) {
ret += "sp";
}
if (config.strikethrough) {
ret += "st";
}
return ret;
}
const createSanitizeMemoized = memoize(
(config: GQLRTEConfiguration) => {
return createSanitize(window as any, {
features: convertGQLRTEConfigToRTEFeatures(config),
});
},
// cache key resolver.
config2CacheKey
);
function sanitizeCommentBody(
config: GQLRTEConfiguration = defaultRTEConfiguration,
source: string
) {
const sanitize = createSanitizeMemoized(config);
// Sanitize and return the HTMLBodyElement for the parsed source.
const sanitized = sanitize(source);
// Count the total number of anchor links in the sanitized output, this is the
// number of links.
const linkCount = sanitized.getElementsByTagName("a").length;
// Remove empty trailing lines.
removeTrailingEmptyLines(sanitized);
return {
body: sanitized.innerHTML,
linkCount,
};
}
export const purify: IntermediateModerationPhase = async ({
comment,
tenant,
}): Promise<IntermediatePhaseResult | void> => {
const { body, linkCount } = sanitizeCommentBody(DOMPurify, comment.body);
const { body, linkCount } = sanitizeCommentBody(tenant.rte, comment.body);
return {
body,
@@ -1,5 +1,4 @@
import striptags from "striptags";
import getHTMLPlainText from "coral-common/helpers/getHTMLPlainText";
import { RepeatPostCommentError } from "coral-server/errors";
import { ACTION_TYPE } from "coral-server/models/action/comment";
import { getLatestRevision } from "coral-server/models/comment/helpers";
@@ -16,14 +15,14 @@ import {
export const repeatPost: IntermediateModerationPhase = async ({
mongo,
htmlStripped,
bodyText,
tenant,
author,
nudge,
redis,
log,
}): Promise<IntermediatePhaseResult | void> => {
if (!htmlStripped) {
if (!bodyText) {
return;
}
@@ -43,13 +42,16 @@ export const repeatPost: IntermediateModerationPhase = async ({
return;
}
const revision = striptags(getLatestRevision(lastComment).body);
const revision = getHTMLPlainText(
getLatestRevision(lastComment).body
).trim();
const compareTo = bodyText.trim();
// Calculate the comment similarity. At the moment, we only do a string
// comparison, so it's either completely equal (they match) or the
// similarity can't be determined (null). This gives us room in the future
// to include a percentage matching.
const similarity = revision.trim() === htmlStripped.trim() ? 1 : null;
const similarity = revision === compareTo ? 1 : null;
if (similarity) {
log.trace({ similarity }, "comment contains repeat content");
@@ -24,16 +24,16 @@ export const toxic: IntermediateModerationPhase = async ({
tenant,
nudge,
log,
htmlStripped,
bodyText,
config,
// FEATURE_FLAG:DISABLE_WARN_USER_OF_TOXIC_COMMENT
comment: { body },
}: Pick<
ModerationPhaseContext,
// FEATURE_FLAG:DISABLE_WARN_USER_OF_TOXIC_COMMENT
"tenant" | "nudge" | "log" | "htmlStripped" | "comment" | "config"
"tenant" | "nudge" | "log" | "bodyText" | "comment" | "config"
>): Promise<IntermediatePhaseResult | void> => {
if (!htmlStripped) {
if (!bodyText) {
return;
}
@@ -92,7 +92,7 @@ export const toxic: IntermediateModerationPhase = async ({
operation: "comments:analyze",
locale: tenant.locale,
body: {
text: htmlStripped,
text: bodyText,
doNotStore,
model,
},
@@ -18,7 +18,7 @@ const list = new WordList();
export const wordList: IntermediateModerationPhase = ({
tenant,
comment,
htmlStripped,
bodyText,
}): IntermediatePhaseResult | void => {
// If there isn't a body, there can't be a bad word!
if (!comment.body) {
@@ -29,7 +29,7 @@ export const wordList: IntermediateModerationPhase = ({
// has pre-mod enabled or not. If the comment was rejected based on the
// wordList, then reject it, otherwise if the moderation setting is
// premod, set it to `premod`.
if (list.test(tenant, "banned", htmlStripped)) {
if (list.test(tenant, "banned", bodyText)) {
// Add the flag related to Trust to the comment.
return {
status: GQLCOMMENT_STATUS.REJECTED,
@@ -48,7 +48,7 @@ export const wordList: IntermediateModerationPhase = ({
// If the wordList has matched the suspect word filter and we haven't disabled
// auto-flagging suspect words, then we should flag the comment!
if (list.test(tenant, "suspect", htmlStripped)) {
if (list.test(tenant, "suspect", bodyText)) {
return {
actions: [
{
@@ -1,6 +1,6 @@
import { Db } from "mongodb";
import striptags from "striptags";
import getHTMLPlainText from "coral-common/helpers/getHTMLPlainText";
import { Promiseable, RequireProperty } from "coral-common/types";
import { Config } from "coral-server/config";
import { Logger } from "coral-server/logger";
@@ -74,9 +74,9 @@ export interface ModerationPhaseContextInput {
export interface ModerationPhaseContext extends ModerationPhaseContextInput {
/**
* htmlStripped is the HTML stripped version of the comment body.
* bodyText is a text version of the comment body.
*/
htmlStripped: string;
bodyText: string;
}
export type RootModerationPhase = (
@@ -116,9 +116,9 @@ export const compose = (
tags: [],
};
// Strip the tags from the comment body so that filters that can't process
// Get text representation of the comment body so that filters that can't process
// HTML can reuse it.
const htmlStripped = striptags(final.body);
const bodyText = getHTMLPlainText(final.body);
// Loop over all the moderation phases and see if we've resolved the status.
for (const phase of phases) {
@@ -129,7 +129,7 @@ export const compose = (
body: final.body,
},
tags: final.tags,
htmlStripped,
bodyText,
metadata: final.metadata,
});
if (result) {
@@ -52,7 +52,7 @@ type Request =
locale: LanguageCode;
body: {
/**
* text is htmlStripped version of the comment text.
* text respresentation of the comment html body.
*/
text: string;
doNotStore: boolean;
@@ -64,7 +64,7 @@ type Request =
locale: LanguageCode;
body: {
/**
* text is htmlStripped version of the comment text.
* text respresentation of the comment html body.
*/
text: string;
commentID: string;
+11
View File
@@ -1176,6 +1176,17 @@ configure-general-staff-input =
.placeholder = E.g. Staff
configure-general-staff-preview = Preview
configure-general-rte-title = Rich-text comments
configure-general-rte-express = Give your community more ways to express themselves beyond plain text with rich-text formatting.
configure-general-rte-richTextComments = Rich-text comments
configure-general-rte-onBasicFeatures = On - bold, italics, block quotes, and bulletted lists
configure-general-rte-additional = Additional rich-text options
configure-general-rte-strikethrough = Strikethrough
configure-general-rte-spoiler = Spoiler
configure-general-rte-spoilerDesc =
Words and phrases formatted as Spoiler are hidden behind a
dark background until the reader chooses to reveal the text.
configure-account-features-title = Commenter account management features
configure-account-features-explanation =
You can enable and disable certain features for your commenters to use
+8
View File
@@ -75,6 +75,14 @@ comments-rte-italic =
comments-rte-blockquote =
.title = Blockquote
comments-rte-bulletedList =
.title = Bulleted List
comments-rte-strikethrough =
.title = Strikethrough
comments-rte-spoiler = Spoiler
comments-remainingCharacters = { $remaining } characters remaining
comments-postCommentFormFake-signInAndJoin = Sign in and Join the Conversation