[CORL-1108] Media Embeds (#3010)

* Create Twitter and YouTube embed components

Uses the `/api/oembed` endpoint to proxy the
oembed requests that the embed components drop
into an iframe.

CORL-1012

* Create preliminary embed link parsing/storage

CORL-1012

* Create preliminary embed section on comments

CORL-1012

* Preliminarily add admin embed config options

CORL-1012

* Show a "missing" message when embed is unavailable

CORL-1012

* Simplify naming of embeds in schema

embedLinks -> embed

CORL-1012

* Rename oEmbedHandler to oembedHandler

CORL-1012

* add backend services for giphy

* search gifs on frontend

* display selected gif

* show gif previews

* display giphy attribution and no results text

* save a gif to a comment

* use embeds feature for gif embeds

* clean up gif/video/tweet display

* style and configure post comment form

* preview and confirm twitter and youtube embeds

* moderate embeds on server

* update reply and edit forms

* update snaps

* fix some of the tests

* fix tests and types

* fix tests

* fix types

* show gifs in moderate cards

* correctly attach embeds to comments

* make gif rating configurable

* make gif rating configurable

* configure giphy api key

* refactor comment form

* only allow embeds if settings enabled

* scale youtube

* resize embeds if necessary

* make tweets and videos responsive

* set maxwidth on tweet embeds

* update copy for embed config

* force gif search results to fit container

* prevent double posting of gifs

* undo hiding html if empmty because now it doesn't contain random break tags

* use downsampled preview images

* update fixtures and snapshots

* remove unused css

* add i18n string

* remove console logs

* Fix styles on logged-out comment form

* click to pause gif in moderation

* style youtube and twitter embeds in mod stream"

* use mp4s for stream gifs

* use mp4 for moderation gifs

* clean up commentform

* fix dom tests

* update rte

* import oembed module with correct casing

* bump rte

* add correct return type for setInterval

* add migration for embeds config

* catch errors from gif search

* return early from iframe container size calculation if width and height are set

* remove unused classnames

* make giphy api key protected

* reorganize tenant embed settings schema

* update schema on backend to support single comment embed instead of array

* move findEmbedLinks to common

* wrap error

* return function for linkify instead of ternary

* remove unused url param

* clean up oembed service

* remove conditional in repeat post check

* use joi to validate giphy responses

* fix types for embeds

* fix optimistic responses

* move attachEmbeds function

* update snapshots

* fix: improved repeatPost checking

* force case change on oembed

* force rename file name

* feat: Rename Embed -> Media

* fix: cleanup of service functions

* fix: moved types

* fix: fixed logic bug

* fix: fixed translation

* show embeds on history comments

* fix: fixed iframe csp and query param bug

* correct validation for twitter oembed

* feat: save youtube still

* fix: typeerror

* fix: fixed errors related to final form

* fix: fixed issue with types

* fix: added docs to the schema

* fix: linting + tests

Co-authored-by: nick-funk <nick.funk@outlook.com>
Co-authored-by: Wyatt Johnson <me@wyattjoh.ca>
This commit is contained in:
Tessa Thornton
2020-07-14 22:16:06 -04:00
committed by GitHub
parent f8234e53ed
commit c59c345756
147 changed files with 6366 additions and 1971 deletions
+2
View File
@@ -92,6 +92,8 @@ const typescriptOverrides = {
"react/prop-types": "off",
"react/no-unescaped-entities": "off",
"no-empty-function": "off",
// (tessalt) disabled because video elements are only used to display gifs, which have no audio
"jsx-a11y/media-has-caption": "off",
}
),
};
+90 -99
View File
@@ -5087,9 +5087,9 @@
}
},
"@coralproject/rte": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-1.0.0.tgz",
"integrity": "sha512-Z+NMytOSdz2qqphcevI9+s3CUzsW/LPzXPxVHAd9qKSlgJ+lr4pvht/gzgcvDvKy1hT88CompJwkqlIBxk0y9w==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-1.1.1.tgz",
"integrity": "sha512-r70xg7arHttiJr4pXCZTTWA86bjT9M+XHDeaYjNpMQrW5rIL+kw7sjoRuiyPLzi88xINMbxffurdKep2Z68bcg==",
"dev": true,
"requires": {
"classnames": "^2.2.6",
@@ -22068,6 +22068,13 @@
"concat-map": "0.0.1"
}
},
"chownr": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz",
"integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==",
"dev": true,
"optional": true
},
"code-point-at": {
"version": "1.1.0",
"resolved": false,
@@ -22127,12 +22134,28 @@
"dev": true,
"optional": true
},
"fs.realpath": {
"version": "1.0.0",
"resolved": false,
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"fs-minipass": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz",
"integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==",
"dev": true,
"optional": true
"optional": true,
"requires": {
"minipass": "^2.6.0"
},
"dependencies": {
"minipass": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
"integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
}
}
}
},
"gauge": {
"version": "2.7.4",
@@ -22245,14 +22268,42 @@
"brace-expansion": "^1.1.7"
}
},
"mkdirp": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
"integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
"minimist": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz",
"integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==",
"dev": true,
"optional": true,
"requires": {
"minimist": "^1.2.5"
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
}
},
"minizlib": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz",
"integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==",
"dev": true,
"optional": true,
"requires": {
"minipass": "^2.2.1"
}
},
"mkdirp": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
},
"ms": {
@@ -22534,6 +22585,22 @@
"dev": true,
"optional": true
},
"tar": {
"version": "4.4.8",
"resolved": "https://registry.npmjs.org/tar/-/tar-4.4.8.tgz",
"integrity": "sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==",
"dev": true,
"optional": true,
"requires": {
"chownr": "^1.1.1",
"fs-minipass": "^1.2.5",
"minipass": "^2.3.4",
"minizlib": "^1.1.1",
"mkdirp": "^0.5.0",
"safe-buffer": "^5.1.2",
"yallist": "^3.0.2"
}
},
"util-deprecate": {
"version": "1.0.2",
"resolved": false,
@@ -22557,6 +22624,13 @@
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz",
"integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==",
"dev": true,
"optional": true
}
}
},
@@ -29139,7 +29213,6 @@
"integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==",
"requires": {
"chownr": "^1.0.1",
"minizlib": "^1.1.0",
"mkdirp": "^0.5.0",
"safe-buffer": "^5.1.1",
"yallist": "^3.0.2"
@@ -38530,35 +38603,6 @@
"minipass": "^3.0.0"
}
},
"minizlib": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz",
"integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==",
"requires": {
"minipass": "^2.9.0"
},
"dependencies": {
"minipass": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
"integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
}
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
},
"yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
}
}
},
"mississippi": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz",
@@ -49223,9 +49267,9 @@
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"squire-rte": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/squire-rte/-/squire-rte-1.10.1.tgz",
"integrity": "sha512-BGTSZmwY3BFFvLaGSz6qFwzdCbRc0EhTW9rw3xNwlEIVHKBFzImDdzT1aM4dWZYyvNvAHknUuPvr/elNU7Nt1g==",
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/squire-rte/-/squire-rte-1.10.2.tgz",
"integrity": "sha512-/IUZbc3+pdZUL+kP6wsUI8brDKtiP+EiIlw6Hj6q1n384NzUqQNa8iMLkeYZKC4o96ToyteY7fhv/DeS1GkWRw==",
"dev": true
},
"sshpk": {
@@ -50805,59 +50849,6 @@
"integrity": "sha512-dQRhbNQkRnaqauC7WqSJ21EEksgT0fYZX2lqXzGkpo8JNig9zGZTYoMGvyI2nWmXlE2VSVXVDu7wLVGu/mQEsg==",
"dev": true
},
"tar": {
"version": "4.4.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz",
"integrity": "sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==",
"dev": true,
"optional": true,
"requires": {
"chownr": "^1.1.1",
"fs-minipass": "^1.2.5",
"minipass": "^2.8.6",
"minizlib": "^1.2.1",
"mkdirp": "^0.5.0",
"safe-buffer": "^5.1.2",
"yallist": "^3.0.3"
},
"dependencies": {
"fs-minipass": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz",
"integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==",
"dev": true,
"optional": true,
"requires": {
"minipass": "^2.6.0"
}
},
"minipass": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
"integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
}
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"optional": true
},
"yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"optional": true
}
}
},
"tar-stream": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.0.tgz",
+1 -1
View File
@@ -155,7 +155,7 @@
"@babel/preset-typescript": "^7.10.1",
"@babel/runtime-corejs3": "^7.10.3",
"@coralproject/npm-run-all": "^4.1.5",
"@coralproject/rte": "^1.0.0",
"@coralproject/rte": "^1.1.1",
"@fluent/react": "^0.11.1",
"@intervolga/optimize-cssnano-plugin": "^1.0.6",
"@types/archiver": "^3.1.0",
@@ -0,0 +1,63 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback, useState } from "react";
import { BaseButton, Flex, Icon } from "coral-ui/components/v2";
import styles from "./Media.css";
interface Props {
still: string | null;
title: string | null;
width: number | null;
height: number | null;
video: string | null;
}
const GiphyMedia: FunctionComponent<Props> = ({
still,
title,
width,
height,
video,
}) => {
const [showAnimated, setShowAnimated] = useState(false);
const toggleImage = useCallback(() => {
setShowAnimated(!showAnimated);
}, [showAnimated]);
return (
<div className={styles.embed}>
{!showAnimated && still && (
<BaseButton onClick={toggleImage} className={styles.toggle}>
<img src={still} className={styles.image} alt={title || ""} />
<Flex
direction="column"
alignItems="center"
justifyContent="center"
className={styles.toggleTrigger}
>
<Icon size="xl" className={styles.playIcon}>
play_circle_outline
</Icon>
<Localized id="moderate-comment-play-gif">
<p className={styles.playText}>Play GIF</p>
</Localized>
</Flex>
</BaseButton>
)}
{showAnimated && video && (
<BaseButton onClick={toggleImage}>
<video
width={width || undefined}
height={height || undefined}
autoPlay
loop
>
<source src={video} type="video/mp4" />
</video>
</BaseButton>
)}
</div>
);
};
export default GiphyMedia;
@@ -0,0 +1,33 @@
.embed {
margin: var(--v2-spacing-2) 0;
}
.toggle {
position: relative;
}
.toggleTrigger {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(101, 105, 107, 0.6);
}
.playIcon {
color: var(--v2-colors-pure-white);
}
.playText {
color: var(--v2-colors-pure-white);
font-size: var(--v2-font-size-2);
font-weight: var(--v2-font-weight-primary-bold);
font-family: var(--v2-font-family-primary);
text-transform: uppercase;
margin: 0;
}
.image {
display: block;
}
@@ -0,0 +1,87 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "coral-framework/lib/relay";
import {} from "coral-ui/components/v2";
import { MediaContainer_comment } from "coral-admin/__generated__/MediaContainer_comment.graphql";
import GiphyMedia from "./GiphyMedia";
import TwitterMedia from "./TwitterMedia";
import YouTubeMedia from "./YouTubeMedia";
interface Props {
comment: MediaContainer_comment;
}
const MediaContainer: FunctionComponent<Props> = ({ comment }) => {
if (!comment || !comment.revision || !comment.revision.media) {
return null;
}
return (
<>
{comment.revision.media.__typename === "GiphyMedia" && (
<GiphyMedia
still={comment.revision.media.still}
video={comment.revision.media.video}
title={comment.revision.media.title}
width={comment.revision.media.width}
height={comment.revision.media.height}
/>
)}
{comment.revision.media.__typename === "TwitterMedia" && (
<TwitterMedia
url={comment.revision.media.url}
width={comment.revision.media.width}
siteID={comment.site.id}
/>
)}
{comment.revision.media.__typename === "YouTubeMedia" && (
<YouTubeMedia
url={comment.revision.media.url}
width={comment.revision.media.width}
height={comment.revision.media.height}
siteID={comment.site.id}
still={comment.revision.media.still}
title={comment.revision.media.title}
/>
)}
</>
);
};
const enhanced = withFragmentContainer<Props>({
comment: graphql`
fragment MediaContainer_comment on Comment {
site {
id
}
revision {
media {
__typename
... on GiphyMedia {
url
title
width
height
still
video
}
... on TwitterMedia {
url
width
}
... on YouTubeMedia {
url
still
title
width
height
}
}
}
}
`,
})(MediaContainer);
export default enhanced;
@@ -29,6 +29,7 @@ import ApproveButton from "./ApproveButton";
import CommentAuthorContainer from "./CommentAuthorContainer";
import FeatureButton from "./FeatureButton";
import MarkersContainer from "./MarkersContainer";
import MediaContainer from "./MediaContainer";
import RejectButton from "./RejectButton";
import styles from "./ModerateCard.css";
@@ -44,7 +45,8 @@ interface Props {
username: string | null;
} | null;
comment: PropTypesOf<typeof MarkersContainer>["comment"] &
PropTypesOf<typeof CommentAuthorContainer>["comment"];
PropTypesOf<typeof CommentAuthorContainer>["comment"] &
PropTypesOf<typeof MediaContainer>["comment"];
settings: PropTypesOf<typeof MarkersContainer>["settings"];
status: "approved" | "rejected" | "undecided";
featured: boolean;
@@ -238,13 +240,12 @@ const ModerateCard: FunctionComponent<Props> = ({
)}
</div>
<div className={styles.contentArea}>
<CommentContent
highlight={highlight}
phrases={phrases}
className={styles.content}
>
{commentBody}
</CommentContent>
<div className={styles.content}>
<CommentContent highlight={highlight} phrases={phrases}>
{commentBody}
</CommentContent>
<MediaContainer comment={comment} />
</div>
{onConversationClick && (
<div className={styles.viewContext}>
<Button iconLeft variant="text" onClick={onConversationClick}>
@@ -404,6 +404,7 @@ const enhanced = withFragmentContainer<Props>({
...MarkersContainer_comment
...ModeratedByContainer_comment
...CommentAuthorContainer_comment
...MediaContainer_comment
}
`,
settings: graphql`
@@ -0,0 +1,26 @@
import React, { FunctionComponent } from "react";
import styles from "./Media.css";
interface Props {
url: string;
width: number | null;
siteID: string;
}
const TwitterMedia: FunctionComponent<Props> = ({ url, width, siteID }) => {
const cleanUrl = encodeURIComponent(url);
return (
<div className={styles.embed}>
<iframe
frameBorder="0"
width={width || 450}
allowFullScreen
title="oEmbed"
src={`/api/oembed?type=twitter&url=${cleanUrl}&siteID=${siteID}`}
/>
</div>
);
};
export default TwitterMedia;
@@ -0,0 +1,63 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback, useState } from "react";
import { BaseButton, Flex, Icon } from "coral-ui/components/v2";
import styles from "./Media.css";
interface Props {
url: string;
still: string;
width: number | null;
height: number | null;
title: string | null;
siteID: string;
}
const YouTubeMedia: FunctionComponent<Props> = ({
url,
still,
title,
width,
height,
siteID,
}) => {
const [showAnimated, setShowAnimated] = useState(false);
const toggleImage = useCallback(() => {
setShowAnimated(!showAnimated);
}, [showAnimated]);
const cleanUrl = encodeURIComponent(url);
return (
<div className={styles.embed}>
{!showAnimated && still && (
<BaseButton onClick={toggleImage} className={styles.toggle}>
<img src={still} className={styles.image} alt={title || ""} />
<Flex
direction="column"
alignItems="center"
justifyContent="center"
className={styles.toggleTrigger}
>
<Icon size="xl" className={styles.playIcon}>
play_circle_outline
</Icon>
<Localized id="moderate-comment-load-video">
<p className={styles.playText}>Load Video</p>
</Localized>
</Flex>
</BaseButton>
)}
{showAnimated && (
<iframe
frameBorder="0"
width={width || 450}
allowFullScreen
title="oEmbed"
src={`/api/oembed?type=youtube&url=${cleanUrl}&siteID=${siteID}`}
/>
)}
</div>
);
};
export default YouTubeMedia;
@@ -42,25 +42,31 @@ exports[`renders approved correctly 1`] = `
<div
className="ModerateCard-contentArea"
>
<CommentContent
<div
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<CommentContent
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<Relay(MediaContainer)
comment={Object {}}
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -179,25 +185,31 @@ exports[`renders correctly 1`] = `
<div
className="ModerateCard-contentArea"
>
<CommentContent
<div
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<CommentContent
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<Relay(MediaContainer)
comment={Object {}}
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -316,25 +328,31 @@ exports[`renders dangling correctly 1`] = `
<div
className="ModerateCard-contentArea"
>
<CommentContent
<div
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<CommentContent
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<Relay(MediaContainer)
comment={Object {}}
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -453,25 +471,31 @@ exports[`renders rejected correctly 1`] = `
<div
className="ModerateCard-contentArea"
>
<CommentContent
<div
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<CommentContent
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<Relay(MediaContainer)
comment={Object {}}
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -599,25 +623,31 @@ exports[`renders reply correctly 1`] = `
<div
className="ModerateCard-contentArea"
>
<CommentContent
<div
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<CommentContent
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<Relay(MediaContainer)
comment={Object {}}
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -736,25 +766,31 @@ exports[`renders story info 1`] = `
<div
className="ModerateCard-contentArea"
>
<CommentContent
<div
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<CommentContent
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
</CommentContent>
<Relay(MediaContainer)
comment={Object {}}
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -911,33 +947,39 @@ exports[`renders tombstoned when comment is deleted 1`] = `
<div
className="ModerateCard-contentArea"
>
<CommentContent
<div
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
<Localized
id="moderate-comment-deleted-body"
<CommentContent
highlight={false}
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
<div
className="ModerateCard-deleted"
<Localized
id="moderate-comment-deleted-body"
>
This comment is no longer available. The commenter has deleted their account.
</div>
</Localized>
</CommentContent>
<div
className="ModerateCard-deleted"
>
This comment is no longer available. The commenter has deleted their account.
</div>
</Localized>
</CommentContent>
<Relay(MediaContainer)
comment={Object {}}
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -17,6 +17,7 @@ import CommentEditingConfig from "./CommentEditingConfig";
import CommentLengthConfig from "./CommentLengthConfig";
import GuidelinesConfig from "./GuidelinesConfig";
import LocaleConfig from "./LocaleConfig";
import MediaLinksConfig from "./MediaLinksConfig";
import ReactionConfigContainer from "./ReactionConfigContainer";
import RTEConfig from "./RTEConfig";
import SitewideCommentingConfig from "./SitewideCommentingConfig";
@@ -52,6 +53,7 @@ const GeneralConfigContainer: React.FunctionComponent<Props> = ({
<ClosedStreamMessageConfig disabled={submitting} />
<ReactionConfigContainer disabled={submitting} settings={settings} />
<StaffConfig disabled={submitting} />
<MediaLinksConfig disabled={submitting} />
</HorizontalGutter>
);
};
@@ -70,7 +72,7 @@ const enhanced = withFragmentContainer<Props>({
...ReactionConfig_formValues @relay(mask: false)
...StaffConfig_formValues @relay(mask: false)
...RTEConfig_formValues @relay(mask: false)
...MediaLinksConfig_formValues @relay(mask: false)
...ReactionConfigContainer_settings
}
`,
@@ -0,0 +1,7 @@
.ratingDesc {
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-500);
padding-left: 26px;
}
@@ -0,0 +1,273 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { Field, FormSpy } from "react-final-form";
import { graphql } from "react-relay";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import {
Condition,
required,
validateWhen,
} from "coral-framework/lib/validation";
import {
FieldSet,
FormField,
FormFieldDescription,
HelperText,
Label,
RadioButton,
} from "coral-ui/components/v2";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import OnOffField from "../../OnOffField";
import Subheader from "../../Subheader";
import APIKeyField from "../Moderation/APIKeyField";
import styles from "./MediaLinksConfig.css";
interface Props {
disabled: boolean;
}
const giphyIsEnabled: Condition = (value, values) =>
Boolean(values.media && values.media.giphy.enabled);
// eslint-disable-next-line no-unused-expressions
graphql`
fragment MediaLinksConfig_formValues on Settings {
media {
twitter {
enabled
}
youtube {
enabled
}
giphy {
enabled
maxRating
key
}
}
}
`;
const MediaLinksConfig: FunctionComponent<Props> = ({ disabled }) => {
return (
<ConfigBox
title={
<Localized id="configure-general-embedLinks-title">
<Header container={<legend />}>Embedded media</Header>
</Localized>
}
container={<FieldSet />}
>
<Localized id="configure-general-embedLinks-desc">
<FormFieldDescription>
Allow commenters to add a YouTube video, Tweet or GIF from GIPHY's
library to the end of their comment
</FormFieldDescription>
</Localized>
<FormField>
<Localized id="configure-general-embedLinks-enableTwitterEmbeds">
<Label component="legend">Allow Twitter media</Label>
</Localized>
<OnOffField
name="media.twitter.enabled"
disabled={disabled}
onLabel={
<Localized id="configure-general-embedLinks-On">
<span>Yes</span>
</Localized>
}
offLabel={
<Localized id="configure-general-embedLinks-Off">
<span>No</span>
</Localized>
}
/>
</FormField>
<FormField>
<Localized id="configure-general-embedLinks-enableYouTubeEmbeds">
<Label component="legend">Enable YouTube media</Label>
</Localized>
<OnOffField
name="media.youtube.enabled"
disabled={disabled}
onLabel={
<Localized id="configure-general-embedLinks-On">
<span>Yes</span>
</Localized>
}
offLabel={
<Localized id="configure-general-embedLinks-Off">
<span>No</span>
</Localized>
}
/>
</FormField>
<FormField>
<Localized id="configure-general-embedLinks-enableGiphyEmbeds">
<Label component="legend">Allow GIFs from GIPHY</Label>
</Localized>
<OnOffField
name="media.giphy.enabled"
disabled={disabled}
onLabel={
<Localized id="configure-general-embedLinks-On">
<span>Yes</span>
</Localized>
}
offLabel={
<Localized id="configure-general-embedLinks-Off">
<span>No</span>
</Localized>
}
/>
</FormField>
<FormSpy subscription={{ values: true }}>
{(props) => {
const giphyDisabled =
!props.values.media ||
!props.values.media.giphy ||
!props.values.media.giphy.enabled;
return (
<>
<FormField>
<Localized id="configure-general-embedLinks-giphyMaxRating">
<Label component="legend">GIF content rating</Label>
</Localized>
<Localized id="configure-general-embedLinks-giphyMaxRating-desc">
<HelperText>
Select the maximum content rating for the GIFs that will
appear in commenters search results
</HelperText>
</Localized>
<Field name="media.giphy.maxRating" type="radio" value="g">
{({ input }) => (
<>
<Localized id="configure-general-embedLinks-giphyMaxRating-g">
<RadioButton
{...input}
id="G"
disabled={giphyDisabled || disabled}
>
G
</RadioButton>
</Localized>
<Localized id="configure-general-embedLinks-giphyMaxRating-g-desc">
<div className={styles.ratingDesc}>
Content that is appropriate for all ages
</div>
</Localized>
</>
)}
</Field>
<Field name="media.giphy.maxRating" type="radio" value="pg">
{({ input }) => (
<>
<Localized id="configure-general-embedLinks-giphyMaxRating-pg">
<RadioButton
{...input}
id="PG"
disabled={giphyDisabled || disabled}
>
PG
</RadioButton>
</Localized>
<Localized id="configure-general-embedLinks-giphyMaxRating-pg-desc">
<div className={styles.ratingDesc}>
Content that is generally safe for everyone, but
parental guidance for children is advised.
</div>
</Localized>
</>
)}
</Field>
<Field name="media.giphy.maxRating" type="radio" value="pg13">
{({ input }) => (
<>
<Localized id="configure-general-embedLinks-giphyMaxRating-pg13">
<RadioButton
{...input}
id="PG13"
disabled={giphyDisabled || disabled}
>
PG-13
</RadioButton>
</Localized>
<Localized id="configure-general-embedLinks-giphyMaxRating-pg13-desc">
<div className={styles.ratingDesc}>
Mild sexual innuendos, mild substance use, mild
profanity, or threatening images. May include images
of semi-naked people, but DOES NOT show real human
genitalia or nudity.
</div>
</Localized>
</>
)}
</Field>
<Field name="media.giphy.maxRating" type="radio" value="r">
{({ input }) => (
<>
<Localized id="configure-general-embedLinks-giphyMaxRating-r">
<RadioButton
{...input}
id="r"
disabled={giphyDisabled || disabled}
>
R
</RadioButton>
</Localized>
<Localized id="configure-general-embedLinks-giphyMaxRating-r-desc">
<div className={styles.ratingDesc}>
Strong language, strong sexual innuendo, violence, and
illegal drug use; not suitable for teens or younger.
No nudity.
</div>
</Localized>
</>
)}
</Field>
</FormField>
<Localized id="configure-general-embedLinks-configuration">
<Subheader>Configuration</Subheader>
</Localized>
<Localized
id="configure-general-embedLinks-configuration-desc"
externalLink={
<ExternalLink
href={"https://developers.giphy.com/docs/api"}
/>
}
>
<HelperText>
For additional information on GIPHYs API please visit:
https://developers.giphy.com/docs/api
</HelperText>
</Localized>
<FormField>
<Localized id="configure-general-embedLinks-giphyAPIKey">
<Label>GIPHY API Key</Label>
</Localized>
<Field name="media.giphy.key">
{({ input, meta }) => (
<APIKeyField
{...input}
disabled={giphyDisabled || disabled}
validate={validateWhen(giphyIsEnabled, required)}
/>
)}
</Field>
</FormField>
</>
);
}}
</FormSpy>
</ConfigBox>
);
};
export default MediaLinksConfig;
@@ -1502,6 +1502,411 @@ appears on the comment stream and in the admin interface.
</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"
>
Embeded media
</legend>
</div>
<div />
</div>
<div
className="ConfigBox-content"
>
<fieldset
className="FieldSet-root Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<p
className="FormFieldDescription-root"
>
Allow commenters to add a YouTube video, Tweet or GIF from GIPHY's library to the end of their comment
</p>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
>
<legend
className="Label-root"
>
Allow Twitter embeds
</legend>
<div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={false}
className="RadioButton-input"
disabled={false}
id="media.twitter.enabled-true"
name="media.twitter.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="true"
/>
<label
className="RadioButton-label"
htmlFor="media.twitter.enabled-true"
>
<span>
Yes
</span>
</label>
</div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={true}
className="RadioButton-input"
disabled={false}
id="media.twitter.enabled-false"
name="media.twitter.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="false"
/>
<label
className="RadioButton-label RadioButton-labelChecked"
htmlFor="media.twitter.enabled-false"
>
<span>
No
</span>
</label>
</div>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
>
<legend
className="Label-root"
>
Allow YouTube embeds
</legend>
<div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={false}
className="RadioButton-input"
disabled={false}
id="media.youtube.enabled-true"
name="media.youtube.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="true"
/>
<label
className="RadioButton-label"
htmlFor="media.youtube.enabled-true"
>
<span>
Yes
</span>
</label>
</div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={true}
className="RadioButton-input"
disabled={false}
id="media.youtube.enabled-false"
name="media.youtube.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="false"
/>
<label
className="RadioButton-label RadioButton-labelChecked"
htmlFor="media.youtube.enabled-false"
>
<span>
No
</span>
</label>
</div>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
>
<legend
className="Label-root"
>
Allow GIFs from GIPHY
</legend>
<div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={false}
className="RadioButton-input"
disabled={false}
id="media.giphy.enabled-true"
name="media.giphy.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="true"
/>
<label
className="RadioButton-label"
htmlFor="media.giphy.enabled-true"
>
<span>
Yes
</span>
</label>
</div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={true}
className="RadioButton-input"
disabled={false}
id="media.giphy.enabled-false"
name="media.giphy.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="false"
/>
<label
className="RadioButton-label RadioButton-labelChecked"
htmlFor="media.giphy.enabled-false"
>
<span>
No
</span>
</label>
</div>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
>
<legend
className="Label-root"
>
GIF content rating
</legend>
<p
className="HelperText-root"
>
Select the maximum content rating for the GIFs that will appear in commenters search results
</p>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={false}
className="RadioButton-input"
disabled={true}
id="G"
name="media.giphy.maxRating"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="g"
/>
<label
className="RadioButton-label"
htmlFor="G"
>
G
</label>
</div>
<div
className="MediaLinksConfig-ratingDesc"
>
Content that is appropriate for all ages
</div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={false}
className="RadioButton-input"
disabled={true}
id="PG"
name="media.giphy.maxRating"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="pg"
/>
<label
className="RadioButton-label"
htmlFor="PG"
>
PG
</label>
</div>
<div
className="MediaLinksConfig-ratingDesc"
>
Content that is generally safe for everyone, but parental guidance for children is advised.
</div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={false}
className="RadioButton-input"
disabled={true}
id="PG13"
name="media.giphy.maxRating"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="pg13"
/>
<label
className="RadioButton-label"
htmlFor="PG13"
>
PG-13
</label>
</div>
<div
className="MediaLinksConfig-ratingDesc"
>
Mild sexual innuendos, mild substance use, mild profanity, or threatening images. May include images of semi-naked people, but DOES NOT show real human genitalia or nudity.
</div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={false}
className="RadioButton-input"
disabled={true}
id="r"
name="media.giphy.maxRating"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="r"
/>
<label
className="RadioButton-label"
htmlFor="r"
>
R
</label>
</div>
<div
className="MediaLinksConfig-ratingDesc"
>
Strong language, strong sexual innuendo, violence, and illegal drug use; not suitable for teens or younger. No nudity.
</div>
</div>
<h3
className="Subheader-root"
>
Configuration
</h3>
<p
className="HelperText-root"
>
For additional information on GIPHYs API please visit:
<a
className="ExternalLink-root"
href="https://developers.giphy.com/docs/api"
rel="noopener noreferrer"
target="_blank"
>
https://developers.giphy.com/docs/api
</a>
</p>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
>
<label
className="Label-root"
>
GIPHY API key
</label>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
>
<label
className="Label-root"
htmlFor="configure-moderation-media.giphy.key"
>
API key
</label>
<div
className="PasswordField-fullWidth PasswordField-root"
>
<div
className="PasswordField-wrapper"
>
<input
autoCapitalize="off"
autoComplete="new-password"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
data-testid="password-field"
disabled={true}
id="configure-moderation-media.giphy.key"
name="media.giphy.key"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder=""
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Show API Key"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</i>
</div>
</div>
</div>
</div>
</div>
</fieldset>
</div>
</fieldset>
</div>
</div>
</div>
@@ -88,13 +88,17 @@ exports[`approves comment in reported queue: dangling 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -354,13 +358,17 @@ exports[`rejects comment in reported queue: dangling 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -617,13 +625,17 @@ exports[`renders reported queue with comments 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -867,13 +879,17 @@ exports[`renders reported queue with comments 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "Don't fool with me",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "Don't fool with me",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -1133,13 +1149,17 @@ exports[`renders reported queue with comments 2`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -1383,13 +1403,17 @@ exports[`renders reported queue with comments 2`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "Don't fool with me",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "Don't fool with me",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -1639,13 +1663,17 @@ exports[`renders reported queue with comments and load more 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "I think I deserve better",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "I think I deserve better",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -88,13 +88,17 @@ exports[`approves comment in rejected queue: dangling 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -351,13 +355,17 @@ exports[`renders rejected queue with comments 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -622,13 +630,17 @@ exports[`renders rejected queue with comments 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "Don't fool with me",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "Don't fool with me",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -899,13 +911,17 @@ exports[`renders rejected queue with comments and load more 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "I think I deserve better",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "I think I deserve better",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -75,13 +75,17 @@ exports[`approves single comment 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -298,13 +302,17 @@ exports[`rejects single comment 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -556,13 +564,17 @@ exports[`renders single comment view 1`] = `
className="ModerateCard-contentArea"
>
<div
className="ModerateCard-content CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
className="ModerateCard-content"
>
<div
className="CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
}
}
}
/>
/>
</div>
<div
className="ModerateCard-viewContext"
>
@@ -95,7 +95,7 @@ Your email address will be used to:
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="email"
name="email"
@@ -482,7 +482,7 @@ Your email address will be used to:
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="email"
name="email"
@@ -512,7 +512,7 @@ Your email address will be used to:
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="confirmEmail"
name="confirmEmail"
@@ -876,7 +876,7 @@ GraphQL request:4:3
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="email"
name="email"
@@ -906,7 +906,7 @@ GraphQL request:4:3
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="confirmEmail"
name="confirmEmail"
@@ -132,7 +132,7 @@ exports[`renders createUsername view 1`] = `
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="username"
name="username"
@@ -321,7 +321,7 @@ GraphQL request:4:3
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="username"
name="username"
@@ -385,7 +385,7 @@ exports[`successfully sets username 1`] = `
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="username"
name="username"
@@ -70,7 +70,7 @@ reset your password.
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="email"
name="email"
@@ -25,7 +25,7 @@ exports[`auth configuration renders all auth enabled 1`] = `
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="email"
name="email"
@@ -395,7 +395,7 @@ exports[`renders sign in view 1`] = `
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="email"
name="email"
@@ -25,7 +25,7 @@ exports[`auth configuration renders all auth enabled 1`] = `
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="email"
name="email"
@@ -60,7 +60,7 @@ exports[`auth configuration renders all auth enabled 1`] = `
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="username"
name="username"
@@ -413,7 +413,7 @@ exports[`renders sign up form 1`] = `
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="email"
name="email"
@@ -448,7 +448,7 @@ exports[`renders sign up form 1`] = `
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorStreamBlue"
className="TextField-input"
disabled={false}
id="username"
name="username"
@@ -287,3 +287,15 @@ export function validateWhen<T = any, V = any>(
return null;
};
}
export function validateWhenOtherwise<T = any, V = any>(
condition: Condition<T, V>,
truthy: Validator<T, V>,
falsy: Validator<T, V>
): Validator<T, V> {
return (value, values) => {
return condition(value, values)
? truthy(value, values)
: falsy(value, values);
};
}
+4
View File
@@ -175,6 +175,10 @@ const CLASSES = {
* dismissButton is the button to dismiss the in review message.
*/
dismissButton: "coral coral-createComment-dismissButton",
/**
* cancel is the button for cancelling the post.
*/
cancel: "coral coral-createComment-cancel",
},
/**
@@ -1,3 +1,4 @@
.root {
composes: root from "~coral-stream/shared/htmlContent.css";
word-break: break-word;
}
@@ -0,0 +1,32 @@
import React, { FunctionComponent } from "react";
interface Props {
url: string;
width?: number | null;
height?: number | null;
video?: string | null;
title?: string | null;
}
const GiphyMedia: FunctionComponent<Props> = ({
url,
title,
video,
width,
height,
}) => {
return video ? (
<video
width={width || undefined}
height={height || undefined}
autoPlay
loop
>
<source src={video} type="video/mp4" />
</video>
) : (
<img src={url} alt={title || ""} />
);
};
export default GiphyMedia;
@@ -0,0 +1,23 @@
.root {
}
.link {
margin-left: var(--v2-spacing-2);
color: var(--palette-primary-main);
}
.frame {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
.container {
position: relative;
height: 0;
overflow: hidden;
max-width: 100%;
}
@@ -0,0 +1,111 @@
import React, {
FunctionComponent,
HTMLProps,
useCallback,
useEffect,
useState,
} from "react";
import styles from "./OEmbed.css";
interface Props {
url: string;
type: string;
loadTimeout?: number;
showLink?: boolean;
className?: string;
width?: number | null;
height?: number | null;
siteID: string;
}
function calculateBottomPadding(width: string, height: string) {
return `${(parseInt(height, 10) / parseInt(width, 10)) * 100}%`;
}
const oEmbed: FunctionComponent<Props> = ({
url,
type,
className,
width,
height,
siteID,
}) => {
const iframeRef = React.createRef<HTMLIFrameElement>();
const containerRef = React.createRef<HTMLDivElement>();
const cleanUrl = encodeURIComponent(url);
const [maxWidth, setMaxWidth] = useState<number | null>(null);
const attrs: HTMLProps<HTMLIFrameElement> = {};
if (width) {
attrs.width = width;
}
if (height) {
attrs.height = height;
}
useEffect(() => {
if (containerRef.current) {
setMaxWidth(containerRef.current.offsetWidth);
}
}, [containerRef.current, maxWidth]);
const onLoad = useCallback(() => {
if (width && height && containerRef && containerRef.current) {
containerRef.current.style.paddingBottom = `${(height / width) * 100}%`;
return;
}
let resizeInterval: number | null = null;
let iterations = 0;
resizeInterval = window.setInterval(() => {
if (iterations > 10 && resizeInterval) {
clearInterval(resizeInterval);
}
if (!iframeRef.current || !iframeRef.current.contentWindow) {
return;
}
let calculatedWidth = `${width}`;
let calculatedHeight = `${height}`;
if (!width) {
calculatedWidth = `${iframeRef.current.contentWindow.document.body.scrollWidth}`;
iframeRef.current.width = `${calculatedWidth}px`;
}
if (!height) {
calculatedHeight = `${iframeRef.current.contentWindow.document.body.scrollHeight}`;
iframeRef.current.height = `${calculatedHeight}px`;
}
if (
containerRef &&
containerRef.current &&
calculatedWidth &&
calculatedHeight
) {
containerRef.current.style.paddingBottom = calculateBottomPadding(
calculatedWidth,
calculatedHeight
);
}
iterations = iterations + 1;
}, 100);
}, [iframeRef, iframeRef.current, containerRef.current, width, height]);
return (
<div className={styles.container} ref={containerRef}>
{maxWidth && (
<iframe
frameBorder="0"
allowFullScreen
scrolling="no"
ref={iframeRef}
title="oEmbed"
src={`/api/oembed?type=${type}&url=${cleanUrl}&maxWidth=${maxWidth}&siteID=${siteID}`}
onLoad={onLoad}
className={styles.frame}
{...attrs}
/>
)}
</div>
);
};
export default oEmbed;
@@ -0,0 +1,15 @@
import React, { FunctionComponent } from "react";
import OEmbed from "./OEmbed";
interface Props {
url: string;
width?: number | null;
siteID: string;
}
const TwitterMedia: FunctionComponent<Props> = ({ url, width, siteID }) => {
return <OEmbed url={url} type="twitter" siteID={siteID} />;
};
export default TwitterMedia;
@@ -0,0 +1,29 @@
import React, { FunctionComponent } from "react";
import OEmbed from "./OEmbed";
interface Props {
url: string;
width?: number | null;
height?: number | null;
siteID: string;
}
const YouTubeMedia: FunctionComponent<Props> = ({
url,
width,
height,
siteID,
}) => {
return (
<OEmbed
url={url}
width={width}
height={height}
type="youtube"
siteID={siteID}
/>
);
};
export default YouTubeMedia;
@@ -0,0 +1,3 @@
export { default as YouTubeMedia } from "./YouTubeMedia";
export { default as TwitterMedia } from "./TwitterMedia";
export { default as GiphyMedia } from "./GiphyMedia";
@@ -24,6 +24,7 @@ export interface CommentProps {
parentAuthorName?: string | null;
userTags?: React.ReactNode;
collapsed?: boolean;
media?: React.ReactNode;
}
const Comment: FunctionComponent<CommentProps> = (props) => {
@@ -68,6 +69,7 @@ const Comment: FunctionComponent<CommentProps> = (props) => {
<HTMLContent className={CLASSES.comment.content}>
{props.body || ""}
</HTMLContent>
{props.media}
{props.footer}
</HorizontalGutter>
</HorizontalGutter>
@@ -37,6 +37,7 @@ import ButtonsBar from "./ButtonsBar";
import EditCommentFormContainer from "./EditCommentForm";
import FeaturedTag from "./FeaturedTag";
import IndentedComment from "./IndentedComment";
import MediaSectionContainer from "./MediaSection/MediaSectionContainer";
import CaretContainer, {
RejectedTombstoneContainer,
} from "./ModerationDropdown";
@@ -400,6 +401,9 @@ export class CommentContainer extends Component<Props, State> {
)}
</Flex>
}
media={
<MediaSectionContainer comment={comment} settings={settings} />
}
footer={
<>
<Flex
@@ -594,6 +598,8 @@ const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))(
...UserTagsContainer_comment
...UsernameWithPopoverContainer_comment
...UsernameContainer_comment
...MediaSectionContainer_comment
...UsernameContainer_comment
}
`,
settings: graphql`
@@ -605,6 +611,7 @@ const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))(
...ReplyCommentFormContainer_settings
...EditCommentFormContainer_settings
...UserTagsContainer_settings
...MediaSectionContainer_settings
}
`,
})(CommentContainer)
@@ -0,0 +1,6 @@
$commentsBorder: var(--v2-colors-grey-300);
.commentFormBox {
border: 1px solid $commentsBorder;
border-radius: var(--v2-round-corners);
}
@@ -1,38 +1,18 @@
import { CoralRTE } from "@coralproject/rte";
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { EventHandler, FunctionComponent, MouseEvent, Ref } from "react";
import { Field, Form } from "react-final-form";
import { OnSubmit } from "coral-framework/lib/form";
import CLASSES from "coral-stream/classes";
import Timestamp from "coral-stream/common/Timestamp";
import ValidationMessage from "coral-stream/common/ValidationMessage";
import {
AriaInfo,
Button,
Flex,
HorizontalGutter,
MatchMedia,
Message,
MessageIcon,
RelativeTime,
} from "coral-ui/components/v2";
import { AriaInfo } from "coral-ui/components/v2";
import { PropTypesOf } from "coral-ui/types";
import {
getCommentBodyValidators,
getHTMLCharacterLength,
} from "../../helpers";
import RemainingCharactersContainer from "../../RemainingCharacters";
import RTEContainer from "../../RTE";
import CommentForm from "../../Stream/CommentForm";
import TopBarLeft from "../TopBarLeft";
import Username from "../Username";
interface FormProps {
body: string;
}
export interface EditCommentFormProps {
id: string;
className?: string;
@@ -44,167 +24,65 @@ export interface EditCommentFormProps {
onSubmit: OnSubmit<any>;
onCancel?: EventHandler<MouseEvent<any>>;
onClose?: EventHandler<MouseEvent<any>>;
initialValues?: FormProps;
initialValues?: any;
rteRef?: Ref<CoralRTE>;
expired?: boolean;
min: number | null;
max: number | null;
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
rteConfig: PropTypesOf<typeof CommentForm>["rteConfig"];
mediaConfig: PropTypesOf<typeof CommentForm>["mediaConfig"];
siteID: string;
}
const EditCommentForm: FunctionComponent<EditCommentFormProps> = (props) => {
const inputID = `comments-editCommentForm-rte-${props.id}`;
return (
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting, pristine, submitError }) => (
<form
className={cn(props.className, CLASSES.editComment.$root)}
autoComplete="off"
onSubmit={handleSubmit}
>
<HorizontalGutter>
<div>
<div>
<TopBarLeft>
{props.author && props.author.username && (
<div>
<TopBarLeft>
{props.author && props.author.username && (
<div>
<Username>{props.author.username}</Username>
</div>
)}
<Timestamp>{props.createdAt}</Timestamp>
</TopBarLeft>
<Username>{props.author.username}</Username>
</div>
<Field
name="body"
validate={getCommentBodyValidators(props.min, props.max)}
>
{/* FIXME: (wyattjoh) reorganize this */}
{({ input, meta }) => (
<>
<HorizontalGutter size="half">
<Localized id="comments-editCommentForm-rteLabel">
<AriaInfo component="label" htmlFor={inputID}>
Edit comment
</AriaInfo>
</Localized>
<Localized
id="comments-editCommentForm-rte"
attrs={{ placeholder: true }}
>
<RTEContainer
config={props.rteConfig}
inputID={inputID}
onChange={(html) => input.onChange(html)}
value={input.value}
placeholder="Edit comment"
ref={props.rteRef}
disabled={submitting || props.expired}
/>
</Localized>
{props.expired ? (
<Localized id="comments-editCommentForm-editTimeExpired">
<ValidationMessage
className={CLASSES.editComment.expiredTime}
>
Edit time has expired. You can no longer edit this
comment. Why not post another one?
</ValidationMessage>
</Localized>
) : (
<>
<Message
className={CLASSES.editComment.remainingTime}
fullWidth
>
<MessageIcon>alarm</MessageIcon>
<Localized
id="comments-editCommentForm-editRemainingTime"
time={
<RelativeTime date={props.editableUntil} live />
}
>
<span>{"Edit: <time></time> remaining"}</span>
</Localized>
</Message>
{meta.touched &&
(meta.error ||
(meta.submitError &&
!meta.dirtySinceLastSubmit)) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
{submitError && (
<ValidationMessage>{submitError}</ValidationMessage>
)}
{props.max && (
<RemainingCharactersContainer
value={input.value}
max={props.max}
/>
)}
</>
)}
</HorizontalGutter>
<Flex
direction="row"
justifyContent="flex-end"
itemGutter="half"
>
{props.expired ? (
<Localized id="comments-editCommentForm-close">
<Button
variant="outline"
disabled={submitting}
onClick={props.onClose}
className={CLASSES.editComment.close}
>
Close
</Button>
</Localized>
) : (
<MatchMedia ltWidth="sm">
{(matches) => (
<>
<Localized id="comments-editCommentForm-cancel">
<Button
color="mono"
variant="outline"
disabled={submitting}
onClick={props.onCancel}
fullWidth={matches}
className={CLASSES.editComment.cancel}
>
Cancel
</Button>
</Localized>
<Localized id="comments-editCommentForm-saveChanges">
<Button
color="stream"
variant="regular"
disabled={
submitting ||
getHTMLCharacterLength(input.value) === 0 ||
pristine
}
type="submit"
fullWidth={matches}
className={CLASSES.editComment.submit}
>
Save Changes
</Button>
</Localized>
</>
)}
</MatchMedia>
)}
</Flex>
</>
)}
</Field>
</HorizontalGutter>
</form>
)}
</Form>
)}
<Timestamp>{props.createdAt}</Timestamp>
</TopBarLeft>
</div>
<CommentForm
siteID={props.siteID}
onSubmit={props.onSubmit}
min={props.min}
max={props.max}
disabled={props.expired}
bodyInputID={inputID}
initialValues={props.initialValues}
onCancel={props.onCancel}
editableUntil={props.editableUntil}
classNameRoot="editComment"
mediaConfig={props.mediaConfig}
expired={props.expired}
placeholder="Edit comment"
placeHolderId="comments-editCommentForm-rte"
disabledMessage={
<Localized id="comments-editCommentForm-editTimeExpired">
<ValidationMessage className={CLASSES.editComment.expiredTime}>
Edit time has expired. You can no longer edit this comment. Why
not post another one?
</ValidationMessage>
</Localized>
}
bodyLabel={
<Localized id="comments-editCommentForm-rteLabel">
<AriaInfo component="label" htmlFor={inputID}>
Edit comment
</AriaInfo>
</Localized>
}
rteConfig={props.rteConfig}
/>
</div>
);
};
@@ -48,7 +48,10 @@ interface State {
export class EditCommentFormContainer extends Component<Props, State> {
private expiredTimer: any;
private intitialValues = { body: this.props.comment.body || "" };
private intitialValues = {
body: this.props.comment.body || "",
media: this.props.comment.revision && this.props.comment.revision.media,
};
public state: State = {
initialized: false,
@@ -98,6 +101,7 @@ export class EditCommentFormContainer extends Component<Props, State> {
await this.props.editComment({
commentID: this.props.comment.id,
body: input.body,
media: input.media,
})
);
if (submitStatus !== "RETRY") {
@@ -133,6 +137,7 @@ export class EditCommentFormContainer extends Component<Props, State> {
}
return (
<EditCommentForm
siteID={this.props.comment.site.id}
id={this.props.comment.id}
rteConfig={this.props.settings.rte}
onSubmit={this.handleOnSubmit}
@@ -144,6 +149,7 @@ export class EditCommentFormContainer extends Component<Props, State> {
createdAt={this.props.comment.createdAt}
editableUntil={this.props.comment.editing.editableUntil!}
expired={this.state.expired}
mediaConfig={this.props.settings.media}
min={
(this.props.settings.charCount.enabled &&
this.props.settings.charCount.min) ||
@@ -158,6 +164,7 @@ export class EditCommentFormContainer extends Component<Props, State> {
);
}
}
const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
// Disable autofocus on ios and enable for the rest.
autofocus: !browserInfo.ios,
@@ -170,12 +177,38 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
id
body
createdAt
revision {
id
media {
__typename
... on GiphyMedia {
url
title
width
height
still
video
}
... on TwitterMedia {
url
width
}
... on YouTubeMedia {
url
width
height
}
}
}
author {
username
}
editing {
editableUntil
}
site {
id
}
}
`,
story: graphql`
@@ -190,6 +223,17 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
min
max
}
media {
twitter {
enabled
}
youtube {
enabled
}
giphy {
enabled
}
}
rte {
...RTEContainer_config
}
@@ -26,6 +26,26 @@ const mutation = graphql`
status
revision {
id
media {
__typename
... on GiphyMedia {
url
title
width
height
still
video
}
... on TwitterMedia {
url
width
}
... on YouTubeMedia {
url
width
height
}
}
}
editing {
edited
@@ -54,7 +74,7 @@ async function commit(
mutation,
variables: {
input: {
...pick(input, ["commentID", "body"]),
...pick(input, ["commentID", "body", "media"]),
clientMutationId: clientMutationId.toString(),
},
},
@@ -66,6 +86,7 @@ async function commit(
status: lookup<GQLComment>(environment, input.commentID)!.status,
revision: {
id: uuidGenerator(),
media: null,
},
editing: {
edited: true,
@@ -0,0 +1,61 @@
.root {
font-family: var(--v2-font-family-primary);
font-size: var(--v2-font-size-2);
color: var(--v2-colors-mono-500);
border-top: 1px solid var(--v2-colors-grey-200);
margin: 0 var(--v2-spacing-2);
padding: var(--v2-spacing-2) 0;
@media (min-width: $breakpoints-xs) {
display: flex;
}
}
.prompt {
margin: 0;
font-weight: var(--v2-font-weight-primary-semi-bold);
}
.buttons {
@media (max-width: $breakpoints-xs) {
justify-content: space-between;
}
}
.promptButton {
@media (max-width: $breakpoints-xs) {
flex: 1;
}
@media (min-width: $breakpoints-xs) {
padding-top: var(--v2-spacing-1) !important;
padding-bottom: var(--v2-spacing-1) !important;
}
}
.promptContainer {
}
.xsFlex {
@media (max-width: $breakpoints-xs) {
display: flex;
}
}
.buttons {
}
.break {
}
.icon {
@media (max-width: $breakpoints-xs) {
}
margin-right: var(--v2-spacing-2);
}
.url {
color: var(--v2-colors-mono-100);
margin: 0;
@media (min-width: $breakpoints-xs) {
}
}
@@ -0,0 +1,96 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { MediaLink } from "coral-common/helpers/findMediaLinks";
import {
Button,
Flex,
HorizontalGutter,
MatchMedia,
} from "coral-ui/components/v2";
import MediaConfirmationIcon from "./MediaConfirmationIcon";
import styles from "./MediaConfirmPrompt.css";
interface Props {
media: MediaLink;
onConfirm: () => void;
onRemove: () => void;
}
const MediaConfirmPrompt: FunctionComponent<Props> = ({
media,
onConfirm,
onRemove,
}) => {
return (
<div className={styles.root}>
<MatchMedia gtWidth="xs">
<div className={styles.icon}>
<MediaConfirmationIcon media={media} />
</div>
</MatchMedia>
<HorizontalGutter spacing={3}>
<div className={styles.xsFlex}>
<MatchMedia lteWidth="xs">
<div className={styles.icon}>
<MediaConfirmationIcon media={media} />
</div>
</MatchMedia>
<div className={styles.promptContainer}>
{media.type === "youtube" && (
<p className={styles.prompt}>
<Localized id="comments-postComment-confirmMedia-youtube">
Add this YouTube video to the end of your comment?
</Localized>
</p>
)}
{media.type === "twitter" && (
<Localized id="comments-postComment-confirmMedia-twitter">
<p className={styles.prompt}>
Add this tweet to the end of your comment?
</p>
</Localized>
)}
<p className={styles.url}>{media.url}</p>
</div>
</div>
<Flex spacing={2} className={styles.buttons}>
<Localized id="comments-postComment-confirmMedia-cancel">
<Button
color="mono"
variant="outline"
className={styles.promptButton}
onClick={onRemove}
>
Cancel
</Button>
</Localized>
{media.type === "twitter" && (
<Localized id="comments-postComment-confirmMedia-add-tweet">
<Button
color="mono"
onClick={onConfirm}
className={styles.promptButton}
>
Add tweet
</Button>
</Localized>
)}
{media.type === "youtube" && (
<Localized id="comments-postComment-confirmMedia-add-video">
<Button
color="mono"
onClick={onConfirm}
className={styles.promptButton}
>
Add video
</Button>
</Localized>
)}
</Flex>
</HorizontalGutter>
</div>
);
};
export default MediaConfirmPrompt;
@@ -0,0 +1,23 @@
.root {
font-family: var(--v2-font-family-primary);
font-size: var(--v2-font-size-2);
color: var(--v2-colors-mono-500);
border-top: 1px solid var(--v2-colors-grey-200);
margin: 0 var(--v2-spacing-2);
padding: var(--v2-spacing-2) 0;
}
.prompt {
margin: 0;
font-weight: var(--v2-font-weight-primary-semi-bold);
}
.url {
color: var(--v2-colors-mono-100);
margin: 0;
}
.boldURL {
font-weight: var(--v2-font-weight-primary-semi-bold);
color: var(--v2-colors-mono-500);
}
@@ -0,0 +1,59 @@
import React, { FunctionComponent } from "react";
import { MediaLink } from "coral-common/helpers/findMediaLinks";
import MediaConfirmPrompt from "./MediaConfirmPrompt";
import MediaPreview from "./MediaPreview";
import styles from "./MediaConfirmation.css";
interface MediaConfig {
giphy: {
enabled: boolean;
};
twitter: {
enabled: boolean;
};
youtube: {
enabled: boolean;
};
}
interface Props {
media: MediaLink & {
confirmed: boolean;
};
onConfirm: () => void;
onRemove: () => void;
config: MediaConfig;
siteID: string;
}
const MediaConfirmation: FunctionComponent<Props> = ({
media,
onConfirm,
config,
onRemove,
siteID,
}) => {
return (
<div className={styles.root}>
{!media.confirmed && (
<MediaConfirmPrompt
media={media}
onConfirm={onConfirm}
onRemove={onRemove}
/>
)}
{media.confirmed && (
<MediaPreview
media={media}
onRemove={onRemove}
config={config}
siteID={siteID}
/>
)}
</div>
);
};
export default MediaConfirmation;
@@ -0,0 +1,4 @@
.twitterIcon {
width: 14px;
height: 12px;
}
@@ -0,0 +1,24 @@
import React, { FunctionComponent } from "react";
import { Icon } from "coral-ui/components/v2";
import { MediaLink } from "coral-common/helpers/findMediaLinks";
import styles from "./MediaConfirmationIcon.css";
import twitterImg from "./twitter.png";
interface Props {
media: MediaLink;
}
const MediaConfirmationIcon: FunctionComponent<Props> = ({ media }) => {
return (
<>
{media.type === "youtube" && <Icon>ondemand_video</Icon>}
{media.type === "twitter" && (
<img className={styles.twitterIcon} src={twitterImg} alt="twitter" />
)}
</>
);
};
export default MediaConfirmationIcon;
@@ -0,0 +1,25 @@
.root {
font-family: var(--v2-font-family-primary);
font-size: var(--v2-font-size-2);
color: var(--v2-colors-mono-500);
border-top: 1px solid var(--v2-colors-grey-200);
margin: 0 var(--v2-spacing-2);
padding: var(--v2-spacing-2) 0;
flex: 1;
}
.url {
color: var(--v2-colors-stream-blue-500);
margin: 0;
font-weight: var(--v2-font-weight-primary-semi-bold);
}
.removeButton {
flex: 1;
display: block;
width: 100%;
margin-top: var(--v2-spacing-1);
}
.icon {
}
@@ -0,0 +1,96 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { MediaLink } from "coral-common/helpers/findMediaLinks";
import { TwitterMedia, YouTubeMedia } from "coral-stream/common/Media";
import {
Button,
ButtonIcon,
Flex,
HorizontalGutter,
MatchMedia,
} from "coral-ui/components/v2";
import MediaConfirmationIcon from "./MediaConfirmationIcon";
import styles from "./MediaPreview.css";
interface MediaConfig {
giphy: {
enabled: boolean;
};
twitter: {
enabled: boolean;
};
youtube: {
enabled: boolean;
};
}
interface Props {
media: MediaLink;
onRemove: () => void;
config: MediaConfig | null;
siteID: string;
}
const MediaPreview: FunctionComponent<Props> = ({
media,
onRemove,
config,
siteID,
}) => {
return (
<div>
<HorizontalGutter spacing={3} className={styles.root}>
<div>
<Flex justifyContent="space-between">
<Flex spacing={2}>
<div className={styles.icon}>
<MediaConfirmationIcon media={media} />
</div>
<a
href={media.url}
target="_blank"
rel="noopener noreferrer"
className={styles.url}
>
{media.url}
</a>
</Flex>
<MatchMedia gteWidth="xs">
<Localized id="comments-postComment-confirmMedia-remove">
<Button onClick={onRemove} color="mono" variant="text" iconLeft>
<ButtonIcon>close</ButtonIcon>
Remove
</Button>
</Localized>
</MatchMedia>
</Flex>
</div>
{media.type === "twitter" && (
<TwitterMedia url={media.url} siteID={siteID} />
)}
{media.type === "youtube" && (
<YouTubeMedia url={media.url} siteID={siteID} />
)}
</HorizontalGutter>
<MatchMedia ltWidth="xs">
<Localized id="comments-postComment-confirmMedia-remove">
<Button
onClick={onRemove}
color="mono"
variant="text"
iconLeft
size="large"
className={styles.removeButton}
>
<ButtonIcon>close</ButtonIcon>
Remove
</Button>
</Localized>
</MatchMedia>
</div>
);
};
export default MediaPreview;
@@ -0,0 +1,3 @@
export { default, default as MediaConfirmation } from "./MediaConfirmation";
export { default as MediaConfirmPrompt } from "./MediaConfirmPrompt";
export { default as MediaPreview } from "./MediaPreview";
Binary file not shown.

After

Width:  |  Height:  |  Size: 752 B

@@ -0,0 +1,3 @@
.button {
background-color: transparent;
}
@@ -0,0 +1,181 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback, useState } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "coral-framework/lib/relay";
import {
GiphyMedia,
TwitterMedia,
YouTubeMedia,
} from "coral-stream/common/Media";
import { Button, ButtonIcon, HorizontalGutter } from "coral-ui/components/v2";
import { MediaSectionContainer_comment } from "coral-stream/__generated__/MediaSectionContainer_comment.graphql";
import { MediaSectionContainer_settings } from "coral-stream/__generated__/MediaSectionContainer_settings.graphql";
import styles from "./MediaSectionContainer.css";
interface Props {
comment: MediaSectionContainer_comment;
settings: MediaSectionContainer_settings;
}
const MediaSectionContainer: FunctionComponent<Props> = ({
comment,
settings,
}) => {
const { revision } = comment;
const [expanded, setExpanded] = useState(false);
const onToggleExpand = useCallback(() => {
setExpanded((v) => !v);
}, []);
if (!revision || !revision.media || !settings.media) {
return null;
}
const { media } = revision;
if (
!settings.media ||
(media.__typename === "TwitterMedia" &&
(!settings.media.twitter || !settings.media.twitter.enabled)) ||
(media.__typename === "YouTubeMedia" &&
(!settings.media.youtube || !settings.media.youtube.enabled)) ||
(media.__typename === "GiphyMedia" &&
(!settings.media.giphy || !settings.media.giphy.enabled))
) {
return null;
}
if (!expanded) {
return (
<Button
iconLeft
variant="outline"
color="stream"
onClick={onToggleExpand}
size="small"
className={styles.button}
>
<ButtonIcon>add</ButtonIcon>
{media.__typename === "TwitterMedia" && (
<Localized id="comments-embedLinks-show-twitter">
Show Tweet
</Localized>
)}
{media.__typename === "YouTubeMedia" && (
<Localized id="comments-embedLinks-show-youtube">
Show video
</Localized>
)}
{media.__typename === "GiphyMedia" && (
<Localized id="comments-embedLinks-show-giphy">Show GIF</Localized>
)}
</Button>
);
}
return (
<HorizontalGutter>
<div>
<Button
variant="outline"
color="stream"
onClick={onToggleExpand}
size="small"
iconLeft
className={styles.button}
>
<ButtonIcon>remove</ButtonIcon>
{media.__typename === "TwitterMedia" && (
<Localized id="comments-embedLinks-hide-twitter">
Hide Tweet
</Localized>
)}
{media.__typename === "GiphyMedia" && (
<Localized id="comments-embedLinks-hide-giphy">Hide GIF</Localized>
)}
{media.__typename === "YouTubeMedia" && (
<Localized id="comments-embedLinks-hide-youtube">
Hide video
</Localized>
)}
</Button>
</div>
{media.__typename === "TwitterMedia" && (
<TwitterMedia
url={media.url}
width={media.width}
siteID={comment.site.id}
/>
)}
{media.__typename === "YouTubeMedia" && (
<YouTubeMedia
url={media.url}
width={media.width}
height={media.height}
siteID={comment.site.id}
/>
)}
{media.__typename === "GiphyMedia" && (
<GiphyMedia
url={media.url}
width={media.width}
height={media.height}
title={media.title}
video={media.video}
/>
)}
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
comment: graphql`
fragment MediaSectionContainer_comment on Comment {
site {
id
}
revision {
media {
__typename
... on GiphyMedia {
url
title
width
height
still
video
}
... on TwitterMedia {
url
width
}
... on YouTubeMedia {
url
width
height
}
}
}
}
`,
settings: graphql`
fragment MediaSectionContainer_settings on Settings {
media {
twitter {
enabled
}
youtube {
enabled
}
giphy {
enabled
}
}
}
`,
})(MediaSectionContainer);
export default enhanced;
@@ -0,0 +1,4 @@
export {
default,
default as MediaSectionContainer,
} from "./MediaSectionContainer";
@@ -198,6 +198,7 @@ async function commit(
body: input.body,
nudge: input.nudge,
clientMutationId: clientMutationId.toString(),
media: input.media,
},
},
optimisticResponse: {
@@ -218,6 +219,7 @@ async function commit(
body: input.body,
revision: {
id: uuidGenerator(),
media: null,
},
parent: {
id: parentComment.id,
@@ -251,6 +253,9 @@ async function commit(
},
},
},
site: {
id: uuidGenerator(),
},
replies: {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
@@ -2,3 +2,10 @@
border-top-left-radius: 0px;
border-top-right-radius: 0px;
}
$commentsBorder: var(--v2-colors-grey-300);
.commentFormBox {
border: 1px solid $commentsBorder;
border-radius: var(--v2-round-corners);
}
@@ -1,6 +1,5 @@
import { CoralRTE } from "@coralproject/rte";
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import { FormApi, FormState } from "final-form";
import React, {
EventHandler,
@@ -9,50 +8,33 @@ import React, {
Ref,
useCallback,
} from "react";
import { Field, Form, FormSpy } from "react-final-form";
import { useViewerEvent } from "coral-framework/lib/events";
import { OnSubmit } from "coral-framework/lib/form";
import CLASSES from "coral-stream/classes";
import ValidationMessage from "coral-stream/common/ValidationMessage";
import { ReplyCommentFocusEvent } from "coral-stream/events";
import {
AriaInfo,
Button,
Flex,
HorizontalGutter,
MatchMedia,
} from "coral-ui/components/v2";
import { AriaInfo } from "coral-ui/components/v2";
import { PropTypesOf } from "coral-ui/types";
import {
getCommentBodyValidators,
getHTMLCharacterLength,
} from "../../helpers";
import RemainingCharactersContainer from "../../RemainingCharacters";
import RTEContainer from "../../RTE";
import CommentForm from "../../Stream/CommentForm";
import ReplyTo from "./ReplyTo";
import styles from "./ReplyCommentForm.css";
interface FormProps {
body: string;
}
export interface ReplyCommentFormProps {
id: string;
className?: string;
onSubmit: OnSubmit<any>;
onCancel?: EventHandler<MouseEvent<any>>;
onChange?: (state: FormState<any>, form: FormApi) => void;
initialValues?: FormProps;
initialValues?: any;
rteRef?: Ref<CoralRTE>;
parentUsername: string | null;
min: number | null;
max: number | null;
disabled?: boolean;
siteID: string;
disabledMessage?: React.ReactNode;
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
mediaConfig: PropTypesOf<typeof CommentForm>["mediaConfig"];
}
const ReplyCommentForm: FunctionComponent<ReplyCommentFormProps> = (props) => {
@@ -61,129 +43,39 @@ const ReplyCommentForm: FunctionComponent<ReplyCommentFormProps> = (props) => {
const onFocus = useCallback(() => {
emitFocusEvent();
}, [emitFocusEvent]);
return (
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting, form, submitError }) => (
<form
className={cn(props.className, CLASSES.createReplyComment.$root)}
autoComplete="off"
onSubmit={handleSubmit}
id={`comments-replyCommentForm-form-${props.id}`}
>
<FormSpy
onChange={(state) => props.onChange && props.onChange(state, form)}
/>
<HorizontalGutter>
<Field
name="body"
validate={getCommentBodyValidators(props.min, props.max)}
>
{/* FIXME: (wyattjoh) reorganize this */}
{({ input, meta }) => (
<>
<HorizontalGutter size="half">
<div>
<Localized id="comments-replyCommentForm-rteLabel">
<AriaInfo component="label" htmlFor={inputID}>
Write a reply
</AriaInfo>
</Localized>
{props.parentUsername && (
<ReplyTo username={props.parentUsername} />
)}
<Localized
id="comments-replyCommentForm-rte"
attrs={{ placeholder: true }}
>
<RTEContainer
config={props.rteConfig}
inputID={inputID}
onFocus={onFocus}
onChange={(html) => input.onChange(html)}
value={input.value}
placeholder="Write a reply"
ref={props.rteRef}
disabled={submitting || props.disabled}
contentClassName={styles.rteContent}
/>
</Localized>
</div>
{props.disabled ? (
<>
{props.disabledMessage && (
<ValidationMessage>
{props.disabledMessage}
</ValidationMessage>
)}
</>
) : (
<>
{meta.touched &&
(meta.error ||
(meta.submitError &&
!meta.dirtySinceLastSubmit)) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
{submitError && (
<ValidationMessage>{submitError}</ValidationMessage>
)}
{props.max && (
<RemainingCharactersContainer
value={input.value}
max={props.max}
/>
)}
</>
)}
</HorizontalGutter>
<MatchMedia ltWidth="sm">
{(matches) => (
<Flex
direction="row"
justifyContent="flex-end"
itemGutter="half"
>
<Localized id="comments-replyCommentForm-cancel">
<Button
color="mono"
variant="outline"
disabled={submitting}
onClick={props.onCancel}
className={CLASSES.createReplyComment.cancel}
fullWidth={matches}
>
Cancel
</Button>
</Localized>
<Localized id="comments-replyCommentForm-submit">
<Button
color="stream"
variant="regular"
disabled={
submitting ||
getHTMLCharacterLength(input.value) === 0 ||
props.disabled
}
type="submit"
className={CLASSES.createReplyComment.submit}
fullWidth={matches}
>
Submit
</Button>
</Localized>
</Flex>
)}
</MatchMedia>
</>
)}
</Field>
</HorizontalGutter>
</form>
)}
</Form>
return (
<div>
<CommentForm
siteID={props.siteID}
onSubmit={props.onSubmit}
initialValues={props.initialValues}
min={props.min}
max={props.max}
disabled={props.disabled}
classNameRoot="createReplyComment"
disabledMessage={props.disabledMessage}
onFocus={onFocus}
onCancel={props.onCancel}
mediaConfig={props.mediaConfig}
placeHolderId="comments-replyCommentForm-rte"
placeholder="Write a reply"
bodyInputID={inputID}
bodyLabel={
<>
<Localized id="comments-replyCommentForm-rteLabel">
<AriaInfo component="label" htmlFor={inputID}>
Write a reply
</AriaInfo>
</Localized>
{props.parentUsername && (
<ReplyTo username={props.parentUsername} />
)}
</>
}
rteConfig={props.rteConfig}
/>
</div>
);
};
@@ -39,6 +39,9 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
revision: {
id: "revision-id",
},
site: {
id: "site-id",
},
},
sessionStorage: createPromisifiedStorage(),
autofocus: false,
@@ -48,6 +51,11 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
min: 3,
max: 100,
},
media: {
giphy: { enabled: false },
twitter: { enabled: false },
youtube: { enabled: false },
},
closeCommenting: {
message: "closed",
},
@@ -133,6 +141,7 @@ it("creates a comment", async () => {
parentRevisionID: "revision-id",
nudge: true,
local: undefined,
media: undefined,
...input,
})
).toBeTruthy();
@@ -116,6 +116,7 @@ export class ReplyCommentFormContainer extends Component<Props, State> {
local: this.props.localReply,
nudge: this.state.nudge,
body: input.body,
media: input.media,
})
);
if (submitStatus !== "RETRY") {
@@ -179,10 +180,12 @@ export class ReplyCommentFormContainer extends Component<Props, State> {
}
return (
<ReplyCommentForm
siteID={this.props.comment.site.id}
id={this.props.comment.id}
rteConfig={this.props.settings.rte}
onSubmit={this.handleOnSubmit}
onChange={this.handleOnChange}
mediaConfig={this.props.settings.media}
initialValues={this.state.initialValues}
onCancel={this.handleOnCancelOrDismiss}
rteRef={this.handleRTERef}
@@ -234,6 +237,17 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
closeCommenting {
message
}
media {
twitter {
enabled
}
youtube {
enabled
}
giphy {
enabled
}
}
rte {
...RTEContainer_config
}
@@ -248,6 +262,9 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
comment: graphql`
fragment ReplyCommentFormContainer_comment on Comment {
id
site {
id
}
author {
username
}
@@ -6,6 +6,19 @@ exports[`renders correctly 1`] = `
disabledMessage="closed"
id="comment-id"
max={100}
mediaConfig={
Object {
"giphy": Object {
"enabled": false,
},
"twitter": Object {
"enabled": false,
},
"youtube": Object {
"enabled": false,
},
}
}
min={3}
onCancel={[Function]}
onChange={[Function]}
@@ -13,6 +26,7 @@ exports[`renders correctly 1`] = `
parentUsername="Joe"
rteConfig={Object {}}
rteRef={[Function]}
siteID="site-id"
/>
`;
@@ -22,6 +36,19 @@ exports[`renders when commenting has been disabled 1`] = `
disabledMessage="commenting disabled"
id="comment-id"
max={100}
mediaConfig={
Object {
"giphy": Object {
"enabled": false,
},
"twitter": Object {
"enabled": false,
},
"youtube": Object {
"enabled": false,
},
}
}
min={3}
onCancel={[Function]}
onChange={[Function]}
@@ -29,6 +56,7 @@ exports[`renders when commenting has been disabled 1`] = `
parentUsername="Joe"
rteConfig={Object {}}
rteRef={[Function]}
siteID="site-id"
/>
`;
@@ -38,6 +66,19 @@ exports[`renders when story has been closed 1`] = `
disabledMessage="story closed"
id="comment-id"
max={100}
mediaConfig={
Object {
"giphy": Object {
"enabled": false,
},
"twitter": Object {
"enabled": false,
},
"youtube": Object {
"enabled": false,
},
}
}
min={3}
onCancel={[Function]}
onChange={[Function]}
@@ -45,6 +86,7 @@ exports[`renders when story has been closed 1`] = `
parentUsername="Joe"
rteConfig={Object {}}
rteRef={[Function]}
siteID="site-id"
/>
`;
@@ -59,6 +101,19 @@ exports[`renders with initialValues 1`] = `
}
}
max={100}
mediaConfig={
Object {
"giphy": Object {
"enabled": false,
},
"twitter": Object {
"enabled": false,
},
"youtube": Object {
"enabled": false,
},
}
}
min={3}
onCancel={[Function]}
onChange={[Function]}
@@ -66,5 +121,6 @@ exports[`renders with initialValues 1`] = `
parentUsername="Joe"
rteConfig={Object {}}
rteRef={[Function]}
siteID="site-id"
/>
`;
@@ -121,6 +121,48 @@ exports[`hide reply button 1`] = `
</React.Fragment>
}
indentLevel={1}
media={
<Relay(MediaSectionContainer)
comment={
Object {
"actionCounts": Object {
"reaction": Object {
"total": 0,
},
},
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
"viewerActionPresence": Object {
"dontAgree": false,
"flag": false,
},
}
}
settings={
Object {
"disableCommenting": Object {
"enabled": false,
},
}
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
@@ -513,6 +555,48 @@ exports[`renders body only 1`] = `
</React.Fragment>
}
indentLevel={1}
media={
<Relay(MediaSectionContainer)
comment={
Object {
"actionCounts": Object {
"reaction": Object {
"total": 0,
},
},
"author": Object {
"badges": Array [],
"id": "author-id",
"username": null,
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
"viewerActionPresence": Object {
"dontAgree": false,
"flag": false,
},
}
}
settings={
Object {
"disableCommenting": Object {
"enabled": false,
},
}
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
@@ -905,6 +989,48 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
</React.Fragment>
}
indentLevel={1}
media={
<Relay(MediaSectionContainer)
comment={
Object {
"actionCounts": Object {
"reaction": Object {
"total": 0,
},
},
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
"viewerActionPresence": Object {
"dontAgree": false,
"flag": false,
},
}
}
settings={
Object {
"disableCommenting": Object {
"enabled": true,
},
}
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
@@ -1297,6 +1423,48 @@ exports[`renders disabled reply when story is closed 1`] = `
</React.Fragment>
}
indentLevel={1}
media={
<Relay(MediaSectionContainer)
comment={
Object {
"actionCounts": Object {
"reaction": Object {
"total": 0,
},
},
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
"viewerActionPresence": Object {
"dontAgree": false,
"flag": false,
},
}
}
settings={
Object {
"disableCommenting": Object {
"enabled": false,
},
}
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
@@ -1697,6 +1865,52 @@ exports[`renders in reply to 1`] = `
</React.Fragment>
}
indentLevel={1}
media={
<Relay(MediaSectionContainer)
comment={
Object {
"actionCounts": Object {
"reaction": Object {
"total": 0,
},
},
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": Object {
"author": Object {
"username": "ParentAuthor",
},
},
"pending": false,
"status": "NONE",
"tags": Array [],
"viewerActionPresence": Object {
"dontAgree": false,
"flag": false,
},
}
}
settings={
Object {
"disableCommenting": Object {
"enabled": false,
},
}
}
/>
}
parentAuthorName="ParentAuthor"
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
@@ -2113,6 +2327,48 @@ exports[`renders username and body 1`] = `
</React.Fragment>
}
indentLevel={1}
media={
<Relay(MediaSectionContainer)
comment={
Object {
"actionCounts": Object {
"reaction": Object {
"total": 0,
},
},
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
"viewerActionPresence": Object {
"dontAgree": false,
"flag": false,
},
}
}
settings={
Object {
"disableCommenting": Object {
"enabled": false,
},
}
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
@@ -2520,6 +2776,48 @@ exports[`shows conversation link 1`] = `
</React.Fragment>
}
indentLevel={1}
media={
<Relay(MediaSectionContainer)
comment={
Object {
"actionCounts": Object {
"reaction": Object {
"total": 0,
},
},
"author": Object {
"badges": Array [],
"id": "author-id",
"username": "Marvin",
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
},
"id": "comment-id",
"lastViewerAction": null,
"parent": null,
"pending": false,
"status": "NONE",
"tags": Array [],
"viewerActionPresence": Object {
"dontAgree": false,
"flag": false,
},
}
}
settings={
Object {
"disableCommenting": Object {
"enabled": false,
},
}
}
/>
}
parentAuthorName={null}
showEditedMarker={false}
staticTopBarRight={<React.Fragment />}
@@ -0,0 +1,17 @@
.root {
padding: var(--v2-spacing-2);
padding-top: 0;
}
.imageWrapper {
padding-top: var(--v2-spacing-2);
border-top: 1px solid var(--v2-colors-grey-200);
position: relative;
display: inline-block;
}
.button {
position: absolute;
top: 16px;
right: 8px;
}
@@ -0,0 +1,38 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { Button, ButtonIcon } from "coral-ui/components/v2";
import styles from "./GifPreview.css";
interface Props {
url: string;
title: string;
onRemove: () => void;
}
const GifPreview: FunctionComponent<Props> = ({ onRemove, url, title }) => {
if (!url) {
return null;
}
return (
<div className={styles.root}>
<div className={styles.imageWrapper}>
<Button
onClick={onRemove}
iconLeft
color="mono"
className={styles.button}
>
<ButtonIcon>cancel</ButtonIcon>
<Localized id="comments-commentForm-gifPreview-remove">
Remove
</Localized>
</Button>
<img src={url} alt={title} />
</div>
</div>
);
};
export default GifPreview;
@@ -0,0 +1,30 @@
import { Environment } from "relay-runtime";
import { GiphyGifSearchResponse } from "coral-common/rest/external/giphy";
import { createFetch, FetchVariables } from "coral-framework/lib/relay";
export const GIF_RESULTS_LIMIT = 10;
interface QueryTypes {
variables: {
query: string;
page: number;
};
}
export const GifSearchFetch = createFetch(
"gifSearch",
async (
environment: Environment,
variables: FetchVariables<QueryTypes>,
{ rest }
) => {
const params = new URLSearchParams();
params.set("query", variables.query);
params.set("offset", `${variables.page * GIF_RESULTS_LIMIT}`);
const url = `/remote-media/gifs?${params.toString()}`;
return rest.fetch<GiphyGifSearchResponse>(url, {
method: "GET",
});
}
);
@@ -0,0 +1,47 @@
.root {
padding: var(--v2-spacing-2);
}
.input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.searchButton {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.noResults {
font-family: var(--v2-font-family-primary);
font-size: var(--v2-font-size-3);
color: var(--v2-colors-mono-100);
}
.loading {
font-family: var(--v2-font-family-primary);
font-size: var(--v2-font-size-3);
color: var(--v2-colors-mono-100);
text-align: center;
}
.error {
font-family: var(--v2-font-family-primary);
font-size: var(--v2-font-size-3);
color: var(--v2-colors-red-500);
}
.results {
overflow: hidden;
}
.result {
/* flex: 1; */
min-height: 0;
min-width: 0;
}
.resultImg {
display: block;
max-width: 100%;
}
@@ -0,0 +1,226 @@
import { Localized } from "@fluent/react/compat";
import React, {
ChangeEvent,
FunctionComponent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { GiphyGif } from "coral-common/rest/external/giphy";
import { useFetch } from "coral-framework/lib/relay";
import {
BaseButton,
Button,
ButtonIcon,
Flex,
HorizontalGutter,
InputLabel,
TextField,
} from "coral-ui/components/v2";
import { GIF_RESULTS_LIMIT, GifSearchFetch } from "./GifSearchFetch";
import GiphyAttribution from "./GiphyAttribution";
import styles from "./GifSelector.css";
interface Props {
onGifSelect: (gif: GiphyGif) => void;
}
const GifSelector: FunctionComponent<Props> = (props) => {
const gifSearchFetch = useFetch(GifSearchFetch);
const [results, setResults] = useState<GiphyGif[]>([]);
const [searchError, setSearchError] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [query, setQuery] = useState<string>("");
const [hasNextPage, setHasNextPage] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const onSearchFieldChange = useCallback(
(evt: ChangeEvent<HTMLInputElement>) => {
setQuery(evt.target.value);
},
[]
);
const searchInput = useRef<HTMLInputElement>(null);
useEffect(() => {
// TODO: why doesn't this work?
if (searchInput && searchInput.current) {
searchInput.current.focus();
}
}, []);
useEffect(() => {
async function search() {
try {
const res = await gifSearchFetch({ query, page });
const { pagination, data } = res;
if (pagination.total_count > pagination.offset * GIF_RESULTS_LIMIT) {
setHasNextPage(true);
} else {
setHasNextPage(false);
}
setSearchError(null);
setResults(data);
} catch (error) {
setSearchError(error.message);
}
setIsLoading(false);
}
let timeout: any | null = null;
if (query && query.length > 1) {
setIsLoading(true);
timeout = setTimeout(() => {
timeout = null;
void search();
}, 200);
} else {
setPage(0);
setResults([]);
setIsLoading(false);
}
return () => {
if (timeout) {
clearTimeout(timeout);
}
};
}, [query, page, setResults, setIsLoading, setPage]);
const nextPage = useCallback(() => {
setPage(page + 1);
}, [page]);
const prevPage = useCallback(() => {
setPage(page - 1);
}, [page]);
const onGifSelect = useCallback((gif: GiphyGif) => {
setResults([]);
setPage(0);
setHasNextPage(false);
setQuery("");
props.onGifSelect(gif);
}, []);
return (
<div className={styles.root}>
<HorizontalGutter>
<HorizontalGutter>
<Localized id="comments-postComment-gifSearch">
<InputLabel>Search for a GIF</InputLabel>
</Localized>
<TextField
className={styles.input}
value={query}
onChange={onSearchFieldChange}
fullWidth
variant="seamlessAdornment"
color="streamBlue"
adornment={
<Button color="stream" className={styles.searchButton}>
<ButtonIcon>search</ButtonIcon>
</Button>
}
/>
</HorizontalGutter>
{isLoading && (
<Localized id="comments-postComment-gifSearch-loading">
<p className={styles.loading}>Loading...</p>
</Localized>
)}
{results.length > 0 && (
<>
<div>
<Flex className={styles.results} justifyContent="space-evenly">
{results.slice(0, results.length / 2).map((result) => (
<BaseButton
key={result.id}
onClick={() => onGifSelect(result)}
className={styles.result}
>
<img
src={result.images.fixed_height_downsampled.url}
alt={result.title}
className={styles.resultImg}
/>
</BaseButton>
))}
</Flex>
<Flex className={styles.results} justifyContent="space-evenly">
{results
.slice(results.length / 2, results.length)
.map((result) => (
<BaseButton
className={styles.result}
key={result.id}
onClick={() => onGifSelect(result)}
>
<img
src={result.images.fixed_height_downsampled.url}
alt={result.title}
className={styles.resultImg}
/>
</BaseButton>
))}
</Flex>
</div>
<GiphyAttribution />
</>
)}
{results.length > 0 && (
<Flex
justifyContent={
results.length > 0 && hasNextPage && page > 0
? "space-between"
: "flex-end"
}
>
{results.length > 0 && page > 0 && (
<Button
onClick={prevPage}
variant="outline"
color="stream"
iconLeft
>
<ButtonIcon>keyboard_arrow_left</ButtonIcon>
Previous
</Button>
)}
{results.length > 0 && hasNextPage && (
<Button
onClick={nextPage}
variant="outline"
color="stream"
iconRight
>
Next
<ButtonIcon>keyboard_arrow_right</ButtonIcon>
</Button>
)}
</Flex>
)}
{searchError && <p className={styles.error}>{searchError}</p>}
{!isLoading &&
!searchError &&
results.length === 0 &&
query.length > 1 && (
<Localized
id="comments-postComment-gifSearch-no-results"
$query={query}
>
<p className={styles.noResults}>
No results found for "{query}"{" "}
</p>
</Localized>
)}
</HorizontalGutter>
</div>
);
};
export default GifSelector;
@@ -0,0 +1,3 @@
.img {
width: 100px;
}
@@ -0,0 +1,27 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { Flex } from "coral-ui/components/v2";
import giphyAttributionImg from "./giphyAttribution.png";
import styles from "./GiphyAttribution.css";
const GiphyAttribution: FunctionComponent = () => {
return (
<Flex justifyContent="flex-end">
<Localized
id="comments-postComment-gifSearch-powered-by-giphy"
attrs={{ alt: true }}
>
<img
className={styles.img}
src={giphyAttributionImg}
alt="powered by giphy"
/>
</Localized>
</Flex>
);
};
export default GiphyAttribution;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,2 @@
export { default, default as GifSelector } from "./GifSelector";
export { default as GifPreview } from "./GifPreview";
@@ -2,13 +2,13 @@ $commentsRTEBorder: var(--v2-colors-grey-300);
$commentsRTEContentBackground: var(--v2-colors-pure-white);
$commentsRTEPlaceholder: var(--v2-colors-grey-400);
.container {
background-color: transparent;
}
.content {
composes: root from "~coral-stream/shared/htmlContent.css";
border: 1px solid $commentsRTEBorder;
border-bottom-width: 0px;
border-top-left-radius: var(--v2-round-corners);
border-top-right-radius: var(--v2-round-corners);
border: 0;
& :global(.coral-rte-spoiler) {
background-color: black;
@@ -20,11 +20,7 @@ $commentsRTEPlaceholder: var(--v2-colors-grey-400);
.toolbar {
background-color: $commentsRTEContentBackground;
border: 1px solid $commentsRTEBorder;
border-top-width: 0px;
border-bottom-left-radius: var(--v2-round-corners);
border-bottom-right-radius: var(--v2-round-corners);
border: 0;
}
.toolbarHidden {
display: none;
@@ -76,6 +76,12 @@ const Localized = React.forwardRef<any, PropTypesOf<typeof LocalizedOriginal>>(
}
);
export interface PasteEvent {
fragment: DocumentFragment;
preventDefault: () => void;
defaultPrevented: boolean;
}
interface Props {
inputID?: string;
/**
@@ -131,6 +137,10 @@ interface Props {
forwardRef?: Ref<CoralRTE>;
features?: RTEFeatures;
toolbarButtons?: React.ReactElement | null;
onWillPaste?: (event: PasteEvent) => void;
}
const RTE: FunctionComponent<Props> = (props) => {
@@ -151,6 +161,7 @@ const RTE: FunctionComponent<Props> = (props) => {
onFocus,
onBlur,
features,
onWillPaste,
...rest
} = props;
@@ -228,6 +239,9 @@ const RTE: FunctionComponent<Props> = (props) => {
</Localized>
);
}
if (props.toolbarButtons) {
x.push(props.toolbarButtons);
}
return x;
}, [features]);
@@ -255,6 +269,7 @@ const RTE: FunctionComponent<Props> = (props) => {
}
)}
contentContainerClassName={cn(
styles.container,
CLASSES.rte.container,
containerClassName
)}
@@ -267,6 +282,7 @@ const RTE: FunctionComponent<Props> = (props) => {
toolbarPosition="bottom"
onBlur={onBlur}
onFocus={onFocus}
onWillPaste={onWillPaste}
sanitizeToDOMFragment={sanitizeToDOMFragment}
{...rest}
/>
@@ -14,6 +14,7 @@ import RTE from "./RTE";
interface Props extends Omit<PropTypesOf<typeof RTE>, "ref"> {
forwardRef: Ref<CoralRTE>;
config: RTEContainer_config;
toolbarButtons?: React.ReactElement | null;
}
const RTEContainer: React.FunctionComponent<Props> = ({ config, ...rest }) => {
@@ -7,7 +7,7 @@ exports[`renders correctly 1`] = `
classNameDisabled=""
contentClassName="coral coral-rte-content RTE-content"
contentClassNameDisabled=""
contentContainerClassName="coral coral-rte-container"
contentContainerClassName="RTE-container coral coral-rte-container"
contentContainerClassNameDisabled=""
features={Array []}
placeholder="Post a comment"
@@ -0,0 +1,18 @@
$commentsBorder: var(--v2-colors-grey-300);
.messageBox {
margin-bottom: -1px;
}
.commentFormBox {
border: 1px solid $commentsBorder;
border-radius: var(--v2-round-corners);
}
.poweredBy {
margin-top: calc(-0.5 * var(--mini-unit));
}
.rteBorderless {
border-top-width: 0;
}
@@ -0,0 +1,322 @@
import { CoralRTE } from "@coralproject/rte";
import { Localized } from "@fluent/react/compat";
import { FormApi, FormState } from "final-form";
import React, {
EventHandler,
FunctionComponent,
MouseEvent,
Ref,
useCallback,
useState,
} from "react";
import { Field, Form, FormSpy } from "react-final-form";
import { findMediaLinks, MediaLink } from "coral-common/helpers/findMediaLinks";
import { useToggleState } from "coral-framework/hooks";
import { FormError, OnSubmit } from "coral-framework/lib/form";
import { PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import ValidationMessage from "coral-stream/common/ValidationMessage";
import {
Button,
ButtonIcon,
Flex,
HorizontalGutter,
MatchMedia,
Message,
MessageIcon,
RelativeTime,
} from "coral-ui/components/v2";
import { getCommentBodyValidators } from "../../helpers";
import RemainingCharactersContainer from "../../RemainingCharacters";
import RTEContainer from "../../RTE";
import MediaField from "./MediaField";
import styles from "./CommentForm.css";
export interface PasteEvent {
fragment: DocumentFragment;
preventDefault: () => void;
defaultPrevented: boolean;
}
interface MediaProps {
type: "giphy" | "twitter" | "youtube";
url: string;
id: string | null;
}
interface FormProps {
body: string;
media?: MediaProps;
}
interface FormSubmitProps extends FormProps, FormError {}
interface Props {
onSubmit: OnSubmit<FormSubmitProps>;
onChange?: (state: FormState<any>, form: FormApi) => void;
initialValues?: FormProps;
min: number | null;
max: number | null;
disabled?: boolean;
disabledMessage?: React.ReactNode;
bodyLabel: React.ReactNode;
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
onFocus?: () => void;
rteRef?: Ref<CoralRTE>;
onCancel?: EventHandler<MouseEvent<any>>;
editableUntil?: string;
expired?: boolean;
submitStatus?: React.ReactNode;
classNameRoot: "createComment" | "editComment" | "createReplyComment";
mediaConfig: PropTypesOf<typeof MediaField>["config"] | null;
placeholder: string;
placeHolderId: string;
bodyInputID: string;
siteID: string;
}
const CommentForm: FunctionComponent<Props> = (props) => {
const [showGifSelector, , toggleGIFSelector] = useToggleState();
const [media, setMedia] = useState<MediaLink | null>(null);
const onSubmit = useCallback(
(values: FormSubmitProps, form: FormApi) => {
// Unset the media.
setMedia(null);
// Submit the form.
return props.onSubmit(values, form);
},
[props.onSubmit, setMedia]
);
const onPaste = useCallback(
(event: PasteEvent) => {
const children = event.fragment.children;
let link = null;
for (let i = 0; i < children.length; i++) {
const item = children.item(i);
if (item && item.textContent) {
const links = findMediaLinks(item.textContent);
if (links.length > 0) {
link = links[0];
break;
}
}
}
if (
link &&
props.mediaConfig &&
((link.type === "twitter" && props.mediaConfig.twitter.enabled) ||
(link.type === "youtube" && props.mediaConfig.youtube.enabled))
) {
setMedia({ ...link });
}
},
[setMedia, props.mediaConfig]
);
return (
<div className={CLASSES[props.classNameRoot].$root}>
<Form onSubmit={onSubmit} initialValues={props.initialValues}>
{({
handleSubmit,
submitting,
submitError,
hasValidationErrors,
form,
pristine,
}) => (
<form
autoComplete="off"
onSubmit={handleSubmit}
id="comments-postCommentForm-form"
>
<HorizontalGutter>
<FormSpy
onChange={(state) => {
return props.onChange && props.onChange(state, form);
}}
/>
<div className={styles.commentFormBox}>
<Field
name="body"
validate={getCommentBodyValidators(props.min, props.max)}
>
{({ input }) => (
<>
<HorizontalGutter size="half">
{props.bodyLabel}
<div>
<Localized
id={props.placeHolderId}
attrs={{ placeholder: true }}
>
<RTEContainer
inputID={props.bodyInputID}
config={props.rteConfig}
onFocus={props.onFocus}
onWillPaste={onPaste}
onChange={(html: string) => {
input.onChange(html);
}}
value={input.value}
placeholder={props.placeholder}
disabled={submitting || props.disabled}
ref={props.rteRef || null}
toolbarButtons={
props.mediaConfig &&
props.mediaConfig.giphy.enabled ? (
<>
<Button
color="mono"
variant={
showGifSelector ? "regular" : "flat"
}
onClick={toggleGIFSelector}
iconLeft
>
<ButtonIcon>add</ButtonIcon>
GIF
</Button>
</>
) : null
}
/>
</Localized>
</div>
</HorizontalGutter>
</>
)}
</Field>
{props.mediaConfig && (
<MediaField
config={props.mediaConfig}
siteID={props.siteID}
media={media}
setMedia={setMedia}
showGIFSelector={showGifSelector}
toggleGIFSelector={toggleGIFSelector}
/>
)}
</div>
{!props.expired && props.editableUntil && (
<Message
className={CLASSES.editComment.remainingTime}
fullWidth
>
<MessageIcon>alarm</MessageIcon>
<Localized
id="comments-editCommentForm-editRemainingTime"
time={<RelativeTime date={props.editableUntil} live />}
>
<span>{"Edit: <time></time> remaining"}</span>
</Localized>
</Message>
)}
{props.disabled ? (
<>
{props.disabledMessage && (
<ValidationMessage>
{props.disabledMessage}
</ValidationMessage>
)}
</>
) : (
<Field
name="body"
subscription={{
touched: true,
error: true,
submitError: true,
value: true,
dirtySinceLastSubmit: true,
}}
>
{({
input: { value },
meta: {
touched,
error,
submitError: localSubmitError,
dirtySinceLastSubmit,
},
}) => (
<>
{touched &&
(error ||
(localSubmitError && !dirtySinceLastSubmit)) && (
<ValidationMessage>
{error || localSubmitError}
</ValidationMessage>
)}
{props.max && (
<RemainingCharactersContainer
value={value}
max={props.max}
/>
)}
</>
)}
</Field>
)}
{submitError && (
<ValidationMessage>{submitError}</ValidationMessage>
)}
<Flex justifyContent="flex-end" spacing={1}>
<MatchMedia ltWidth="sm">
{(matches) => (
<>
{props.onCancel && (
<Localized id="comments-commentForm-cancel">
<Button
color="mono"
variant="outline"
disabled={submitting}
onClick={props.onCancel}
fullWidth={matches}
className={CLASSES[props.classNameRoot].cancel}
>
Cancel
</Button>
</Localized>
)}
<Localized
id={
props.editableUntil
? "comments-commentForm-saveChanges"
: "comments-commentForm-submit"
}
>
<Button
color="stream"
variant="regular"
disabled={
hasValidationErrors ||
submitting ||
props.disabled ||
(!!props.editableUntil && pristine)
}
type="submit"
fullWidth={matches}
className={CLASSES[props.classNameRoot].submit}
>
{props.editableUntil ? "Save changes" : "Submit"}
</Button>
</Localized>
</>
)}
</MatchMedia>
</Flex>
{props.submitStatus}
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
};
export default CommentForm;
@@ -0,0 +1,101 @@
import React, { FunctionComponent, useCallback } from "react";
import { useField } from "react-final-form";
import { isMediaLink, MediaLink } from "coral-common/helpers/findMediaLinks";
import { GiphyGif } from "coral-common/rest/external/giphy";
import {
MediaConfirmPrompt,
MediaPreview,
} from "../../Comment/MediaConfirmation";
import GifSelector, { GifPreview } from "../../GifSelector";
interface Props {
showGIFSelector: boolean;
toggleGIFSelector: () => void;
siteID: string;
config: MediaConfig;
media: MediaLink | null;
setMedia: (media: MediaLink | null) => void;
}
interface Media {
type: "giphy" | "twitter" | "youtube";
url: string;
id?: string;
}
interface MediaConfig {
giphy: {
enabled: boolean;
};
twitter: {
enabled: boolean;
};
youtube: {
enabled: boolean;
};
}
const MediaField: FunctionComponent<Props> = (props) => {
const field = useField<Media | undefined>("media", {
initialValue: undefined,
});
const onGIFSelect = useCallback(
(gif: GiphyGif) => {
field.input.onChange({
type: "giphy",
id: gif.id,
url: gif.images.original.url,
});
props.toggleGIFSelector();
},
[props.toggleGIFSelector]
);
const onGIFRemove = useCallback(() => {
field.input.onChange(undefined);
}, [field.input]);
const onConfirmMedia = useCallback(() => {
field.input.onChange(props.media!);
props.setMedia(null);
}, [props.media, props.setMedia, field.input]);
const onRemoveMedia = useCallback(() => {
field.input.onChange(null);
props.setMedia(null);
}, [props.media, props.setMedia, field.input]);
// Grab the reference to the media object.
const media = field.input.value;
return (
<>
{props.showGIFSelector && <GifSelector onGifSelect={onGIFSelect} />}
{props.media && (
<MediaConfirmPrompt
media={props.media}
onConfirm={onConfirmMedia}
onRemove={onRemoveMedia}
/>
)}
{media &&
media.url &&
(isMediaLink(media) ? (
<MediaPreview
config={props.config}
media={media}
siteID={props.siteID}
onRemove={onRemoveMedia}
/>
) : (
<GifPreview url={media.url} onRemove={onGIFRemove} title="" />
))}
</>
);
};
export default MediaField;
@@ -0,0 +1 @@
export { default } from "./CommentForm";
@@ -15,7 +15,7 @@ import {
SetCommentIDMutation,
withSetCommentIDMutation,
} from "coral-stream/mutations";
import { Box, Flex, Icon } from "coral-ui/components/v2";
import { Box, Flex, HorizontalGutter, Icon } from "coral-ui/components/v2";
import { Button } from "coral-ui/components/v3";
import { FeaturedCommentContainer_comment as CommentData } from "coral-stream/__generated__/FeaturedCommentContainer_comment.graphql";
@@ -24,6 +24,7 @@ import { FeaturedCommentContainer_story as StoryData } from "coral-stream/__gene
import { FeaturedCommentContainer_viewer as ViewerData } from "coral-stream/__generated__/FeaturedCommentContainer_viewer.graphql";
import { UserTagsContainer } from "../../Comment";
import MediaSectionContainer from "../../Comment/MediaSection";
import ReactionButtonContainer from "../../Comment/ReactionButton";
import { UsernameWithPopoverContainer } from "../../Comment/Username";
@@ -61,13 +62,18 @@ const FeaturedCommentContainer: FunctionComponent<Props> = (props) => {
className={cn(CLASSES.featuredComment.$root, styles.root)}
data-testid={`featuredComment-${comment.id}`}
>
<HTMLContent className={cn(styles.body, CLASSES.featuredComment.content)}>
{comment.body || ""}
</HTMLContent>
<HorizontalGutter>
<HTMLContent
className={cn(styles.body, CLASSES.featuredComment.content)}
>
{comment.body || ""}
</HTMLContent>
<MediaSectionContainer comment={comment} settings={settings} />
</HorizontalGutter>
<Flex
direction="row"
alignItems="center"
mt={4}
mt={3}
className={CLASSES.featuredComment.authorBar.$root}
>
{comment.author && (
@@ -188,6 +194,7 @@ const enhanced = withSetCommentIDMutation(
replyCount
...UsernameWithPopoverContainer_comment
...ReactionButtonContainer_comment
...MediaSectionContainer_comment
...UserTagsContainer_comment
}
`,
@@ -195,6 +202,7 @@ const enhanced = withSetCommentIDMutation(
fragment FeaturedCommentContainer_settings on Settings {
...ReactionButtonContainer_settings
...UserTagsContainer_settings
...MediaSectionContainer_settings
}
`,
})(FeaturedCommentContainer)
@@ -182,8 +182,9 @@ async function commit(
variables: {
input: {
storyID: input.storyID,
body: input.body,
body: input.body || "",
nudge: input.nudge,
media: input.media,
clientMutationId: clientMutationId.toString(),
},
},
@@ -202,11 +203,15 @@ async function commit(
badges: viewer.badges,
ignoreable: false,
},
revision: {
site: {
id: uuidGenerator(),
},
revision: {
id: uuidGenerator(),
media: null,
},
parent: null,
body: input.body,
body: input.body || "",
editing: {
editableUntil: new Date(Date.now() + 10000).toISOString(),
edited: false,
@@ -1,7 +1,14 @@
$commentsBorder: var(--v2-colors-grey-300);
.messageBox {
margin-bottom: -1px;
}
.commentFormBox {
border: 1px solid $commentsBorder;
border-radius: var(--v2-round-corners);
}
.poweredBy {
margin-top: calc(-0.5 * var(--mini-unit));
}
@@ -2,35 +2,30 @@ import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import { FormApi, FormState } from "final-form";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form, FormSpy } from "react-final-form";
import { useViewerEvent } from "coral-framework/lib/events";
import { FormError, OnSubmit } from "coral-framework/lib/form";
import { GQLSTORY_MODE } from "coral-framework/schema";
import { PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import ValidationMessage from "coral-stream/common/ValidationMessage";
import { CreateCommentFocusEvent } from "coral-stream/events";
import {
AriaInfo,
Button,
Flex,
HorizontalGutter,
} from "coral-ui/components/v2";
import { AriaInfo } from "coral-ui/components/v2";
import {
getCommentBodyValidators,
getHTMLCharacterLength,
} from "../../helpers";
import RemainingCharactersContainer from "../../RemainingCharacters";
import RTEContainer from "../../RTE";
import CommentForm from "../CommentForm";
import MessageBoxContainer from "../MessageBoxContainer";
import PostCommentSubmitStatusContainer from "./PostCommentSubmitStatusContainer";
import styles from "./PostCommentForm.css";
interface MediaProps {
type: "twitter" | "youtube" | "giphy";
url: string;
id: string | null;
}
interface FormProps {
body: string;
media?: MediaProps;
}
interface FormSubmitProps extends FormProps, FormError {}
@@ -44,24 +39,26 @@ interface StorySettings {
interface Props {
onSubmit: OnSubmit<FormSubmitProps>;
onChange?: (state: FormState<any>, form: FormApi) => void;
initialValues?: FormProps;
initialValues?: any;
min: number | null;
max: number | null;
disabled?: boolean;
disabledMessage?: React.ReactNode;
submitStatus: PropTypesOf<PostCommentSubmitStatusContainer>["status"];
showMessageBox?: boolean;
siteID: string;
story: PropTypesOf<typeof MessageBoxContainer>["story"] & StorySettings;
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
rteConfig: PropTypesOf<typeof CommentForm>["rteConfig"];
mediaConfig: PropTypesOf<typeof CommentForm>["mediaConfig"];
}
const PostCommentForm: FunctionComponent<Props> = (props) => {
const isQA =
props.story.settings && props.story.settings.mode === GQLSTORY_MODE.QA;
const emitFocusEvent = useViewerEvent(CreateCommentFocusEvent);
const onFocus = useCallback(() => {
emitFocusEvent();
}, [emitFocusEvent]);
const isQA =
props.story.settings && props.story.settings.mode === GQLSTORY_MODE.QA;
return (
<div className={CLASSES.createComment.$root}>
{props.showMessageBox && (
@@ -70,125 +67,47 @@ const PostCommentForm: FunctionComponent<Props> = (props) => {
className={cn(CLASSES.createComment.message, styles.messageBox)}
/>
)}
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting, submitError, form }) => (
<form
autoComplete="off"
onSubmit={handleSubmit}
id="comments-postCommentForm-form"
>
<FormSpy
onChange={(state) =>
props.onChange && props.onChange(state, form)
}
/>
<HorizontalGutter>
<Field
name="body"
validate={getCommentBodyValidators(props.min, props.max)}
<CommentForm
siteID={props.siteID}
onSubmit={props.onSubmit}
onChange={props.onChange}
min={props.min}
initialValues={props.initialValues}
max={props.max}
disabled={props.disabled}
disabledMessage={props.disabledMessage}
rteConfig={props.rteConfig}
placeHolderId="comments-postCommentForm-rte"
placeholder="Post a comment"
mediaConfig={props.mediaConfig}
onFocus={onFocus}
classNameRoot="createComment"
bodyInputID="comments-postCommentForm-field"
bodyLabel={
isQA ? (
<Localized id="qa-postQuestionForm-rteLabel">
<AriaInfo
component="label"
htmlFor="comments-postCommentForm-field"
>
{({ input, meta }) => (
<>
<HorizontalGutter size="half">
{isQA ? (
<Localized id="qa-postQuestionForm-rteLabel">
<AriaInfo
component="label"
htmlFor="comments-postCommentForm-field"
>
Post a question
</AriaInfo>
</Localized>
) : (
<Localized id="comments-postCommentForm-rteLabel">
<AriaInfo
component="label"
htmlFor="comments-postCommentForm-field"
>
Post a comment
</AriaInfo>
</Localized>
)}
<Localized
id={
isQA
? "qa-postQuestionForm-rte"
: "comments-postCommentForm-rte"
}
attrs={{ placeholder: true }}
>
<RTEContainer
inputID="comments-postCommentForm-field"
config={props.rteConfig}
onFocus={onFocus}
onChange={(html) => input.onChange(html)}
contentClassName={
props.showMessageBox
? styles.rteBorderless
: undefined
}
value={input.value}
placeholder="Post a comment"
disabled={submitting || props.disabled}
/>
</Localized>
{props.disabled ? (
<>
{props.disabledMessage && (
<ValidationMessage>
{props.disabledMessage}
</ValidationMessage>
)}
</>
) : (
<>
{meta.touched &&
(meta.error ||
(meta.submitError &&
!meta.dirtySinceLastSubmit)) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
{submitError && (
<ValidationMessage>{submitError}</ValidationMessage>
)}
<PostCommentSubmitStatusContainer
status={props.submitStatus}
/>
{props.max && (
<RemainingCharactersContainer
value={input.value}
max={props.max}
/>
)}
</>
)}
</HorizontalGutter>
<Flex direction="column" alignItems="flex-end">
<Localized id="comments-postCommentForm-submit">
<Button
color="stream"
variant="regular"
className={CLASSES.createComment.submit}
disabled={
submitting ||
getHTMLCharacterLength(input.value) === 0 ||
props.disabled
}
type="submit"
>
Submit
</Button>
</Localized>
</Flex>
</>
)}
</Field>
</HorizontalGutter>
</form>
)}
</Form>
Post a question
</AriaInfo>
</Localized>
) : (
<Localized id="comments-postCommentForm-rteLabel">
<AriaInfo
component="label"
htmlFor="comments-postCommentForm-field"
>
Post a comment
</AriaInfo>
</Localized>
)
}
submitStatus={
<PostCommentSubmitStatusContainer status={props.submitStatus} />
}
/>
</div>
);
};
@@ -27,6 +27,9 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
story: {
id: "story-id",
isClosed: false,
site: {
id: "site-id",
},
settings: {
messageBox: {
enabled: false,
@@ -41,6 +44,11 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
min: 3,
max: 100,
},
media: {
giphy: { enabled: false },
twitter: { enabled: false },
youtube: { enabled: false },
},
closeCommenting: {
message: "closed",
},
@@ -156,10 +164,11 @@ it("creates a comment", async () => {
await wait(() =>
expect(
createCommentStub.calledWith({
...input,
storyID,
nudge: true,
commentsOrderBy: "CREATED_AT_ASC",
...input,
media: undefined,
})
).toBeTruthy()
);
@@ -35,6 +35,7 @@ import {
} from "../../helpers";
import RefreshSettingsFetch from "../../RefreshSettingsFetch";
import { RTE_RESET_VALUE } from "../../RTE/RTE";
import CommentForm from "../CommentForm";
import {
CreateCommentMutation,
withCreateCommentMutation,
@@ -60,7 +61,7 @@ interface Props {
interface State {
/** nudge will turn on the nudging behavior on the server */
nudge: boolean;
initialValues?: PropTypesOf<typeof PostCommentForm>["initialValues"];
initialValues?: PropTypesOf<typeof CommentForm>["initialValues"];
initialized: boolean;
keepFormWhenClosed: boolean;
submitStatus: SubmitStatus | null;
@@ -118,9 +119,10 @@ export class PostCommentFormContainer extends Component<Props, State> {
}
};
private handleOnSubmit: PropTypesOf<
typeof PostCommentForm
>["onSubmit"] = async (input, form) => {
private handleOnSubmit: PropTypesOf<typeof CommentForm>["onSubmit"] = async (
input,
form
) => {
try {
if (this.props.tab === "FEATURED_COMMENTS") {
this.props.onChangeTab("ALL_COMMENTS");
@@ -132,6 +134,7 @@ export class PostCommentFormContainer extends Component<Props, State> {
nudge: this.state.nudge,
commentsOrderBy: this.props.commentsOrderBy,
body: input.body,
media: input.media,
})
);
if (submitStatus !== "RETRY") {
@@ -160,7 +163,7 @@ export class PostCommentFormContainer extends Component<Props, State> {
return;
};
private handleOnChange: PropTypesOf<typeof PostCommentForm>["onChange"] = (
private handleOnChange: PropTypesOf<typeof CommentForm>["onChange"] = (
state,
form
) => {
@@ -229,10 +232,12 @@ export class PostCommentFormContainer extends Component<Props, State> {
return (
<PostCommentForm
siteID={this.props.story.site.id}
story={this.props.story}
onSubmit={this.handleOnSubmit}
onChange={this.handleOnChange}
initialValues={this.state.initialValues}
mediaConfig={this.props.settings.media}
rteConfig={this.props.settings.rte}
min={
(this.props.settings.charCount.enabled &&
@@ -288,6 +293,17 @@ const enhanced = withContext(({ sessionStorage }) => ({
closeCommenting {
message
}
media {
twitter {
enabled
}
youtube {
enabled
}
giphy {
enabled
}
}
rte {
...RTEContainer_config
}
@@ -298,6 +314,9 @@ const enhanced = withContext(({ sessionStorage }) => ({
id
isClosed
...MessageBoxContainer_story
site {
id
}
settings {
messageBox {
enabled
@@ -1,6 +1,12 @@
$commentsBorder: var(--v2-colors-grey-300);
.root {
}
.rteContainer {
border: 1px solid $commentsBorder;
border-radius: var(--v2-round-corners);
}
.messageBox {
margin-bottom: -1px;
}
@@ -48,7 +48,7 @@ const PostCommentFormFake: FunctionComponent<Props> = (props) => {
/>
)}
<HorizontalGutter className={styles.root}>
<div>
<div className={styles.rteContainer}>
<Localized
id={
isQA
@@ -0,0 +1,7 @@
$commentsRTEBorder: var(--v2-colors-grey-300);
$commentsRTEContentBackground: var(--v2-colors-pure-white);
.root {
border: 1px solid $commentsRTEBorder;
border-radius: var(--v2-round-corners);
}
@@ -10,6 +10,19 @@ exports[`renders correctly 1`] = `
}
}
max={100}
mediaConfig={
Object {
"giphy": Object {
"enabled": false,
},
"twitter": Object {
"enabled": false,
},
"youtube": Object {
"enabled": false,
},
}
}
min={3}
onChange={[Function]}
onSubmit={[Function]}
@@ -21,6 +34,7 @@ exports[`renders correctly 1`] = `
}
}
showMessageBox={false}
siteID="site-id"
story={
Object {
"id": "story-id",
@@ -31,6 +45,9 @@ exports[`renders correctly 1`] = `
},
"mode": "COMMENTS",
},
"site": Object {
"id": "site-id",
},
}
}
submitStatus={null}
@@ -51,6 +68,9 @@ exports[`renders when commenting has been disabled (collapsing) 1`] = `
},
"mode": "COMMENTS",
},
"site": Object {
"id": "site-id",
},
}
}
/>
@@ -66,6 +86,19 @@ exports[`renders when commenting has been disabled (non-collapsing) 1`] = `
}
}
max={100}
mediaConfig={
Object {
"giphy": Object {
"enabled": false,
},
"twitter": Object {
"enabled": false,
},
"youtube": Object {
"enabled": false,
},
}
}
min={3}
onChange={[Function]}
onSubmit={[Function]}
@@ -77,6 +110,7 @@ exports[`renders when commenting has been disabled (non-collapsing) 1`] = `
}
}
showMessageBox={false}
siteID="site-id"
story={
Object {
"id": "story-id",
@@ -87,6 +121,9 @@ exports[`renders when commenting has been disabled (non-collapsing) 1`] = `
},
"mode": "COMMENTS",
},
"site": Object {
"id": "site-id",
},
}
}
submitStatus={null}
@@ -107,6 +144,9 @@ exports[`renders when story has been closed (collapsing) 1`] = `
},
"mode": "COMMENTS",
},
"site": Object {
"id": "site-id",
},
}
}
/>
@@ -122,6 +162,19 @@ exports[`renders when story has been closed (non-collapsing) 1`] = `
}
}
max={100}
mediaConfig={
Object {
"giphy": Object {
"enabled": false,
},
"twitter": Object {
"enabled": false,
},
"youtube": Object {
"enabled": false,
},
}
}
min={3}
onChange={[Function]}
onSubmit={[Function]}
@@ -133,6 +186,7 @@ exports[`renders when story has been closed (non-collapsing) 1`] = `
}
}
showMessageBox={false}
siteID="site-id"
story={
Object {
"id": "story-id",
@@ -143,6 +197,9 @@ exports[`renders when story has been closed (non-collapsing) 1`] = `
},
"mode": "COMMENTS",
},
"site": Object {
"id": "site-id",
},
}
}
submitStatus={null}
@@ -165,6 +222,19 @@ exports[`renders when user is scheduled to be deleted 1`] = `
}
}
max={100}
mediaConfig={
Object {
"giphy": Object {
"enabled": false,
},
"twitter": Object {
"enabled": false,
},
"youtube": Object {
"enabled": false,
},
}
}
min={3}
onChange={[Function]}
onSubmit={[Function]}
@@ -176,6 +246,7 @@ exports[`renders when user is scheduled to be deleted 1`] = `
}
}
showMessageBox={false}
siteID="site-id"
story={
Object {
"id": "story-id",
@@ -186,6 +257,9 @@ exports[`renders when user is scheduled to be deleted 1`] = `
},
"mode": "COMMENTS",
},
"site": Object {
"id": "site-id",
},
}
}
submitStatus={null}
@@ -202,6 +276,19 @@ exports[`renders with initialValues 1`] = `
}
}
max={100}
mediaConfig={
Object {
"giphy": Object {
"enabled": false,
},
"twitter": Object {
"enabled": false,
},
"youtube": Object {
"enabled": false,
},
}
}
min={3}
onChange={[Function]}
onSubmit={[Function]}
@@ -213,6 +300,7 @@ exports[`renders with initialValues 1`] = `
}
}
showMessageBox={false}
siteID="site-id"
story={
Object {
"id": "story-id",
@@ -223,6 +311,9 @@ exports[`renders with initialValues 1`] = `
},
"mode": "COMMENTS",
},
"site": Object {
"id": "site-id",
},
}
}
submitStatus={null}
@@ -11,7 +11,9 @@ exports[`renders correctly 1`] = `
<ForwardRef(forwardRef)
className="PostCommentFormFake-root"
>
<div>
<div
className="PostCommentFormFake-rteContainer"
>
<Localized
attrs={
Object {
@@ -1,28 +1,44 @@
import {
composeValidators,
Condition,
required,
validateMaxLength,
validateMinLength,
validateWhenOtherwise,
Validator,
} from "coral-framework/lib/validation";
import getHTMLCharacterLength from "./getHTMLCharacterLength";
const hasGIFAttached: Condition = (value, values) =>
!!values.media && values.media.type === "giphy" && !!values.media.url;
function getLengthValidators(min: number | null, max: number | null) {
const validators: Validator[] = [];
if (min) {
validators.push(required, validateMinLength(min, getHTMLCharacterLength));
}
if (max) {
validators.push(validateMaxLength(max, getHTMLCharacterLength));
}
return composeValidators(...validators);
}
/**
* getBodyValidators will return validators based on given min & max parameters.
*
* @param min minimum length or null
* @param max maximum length or null
*/
export default function getBodyValidators(
export default function getCommentBodyValdiators(
min: number | null,
max: number | null
) {
const validators = [required];
if (min) {
validators.push(validateMinLength(min, getHTMLCharacterLength));
}
if (max) {
validators.push(validateMaxLength(max, getHTMLCharacterLength));
}
return composeValidators(...validators);
return validateWhenOtherwise(
hasGIFAttached,
getLengthValidators(null, max),
getLengthValidators(min || 1, max)
);
}
@@ -2,8 +2,6 @@
margin-top: var(--v2-spacing-2);
}
.content {}
.title {
margin-bottom: var(--v2-spacing-2);
@@ -70,7 +70,7 @@ const DownloadCommentsContainer: FunctionComponent<Props> = ({ viewer }) => {
return (
<div className={cn(styles.root, CLASSES.downloadCommentHistory.$root)}>
<Flex justifyContent="space-between" alignItems="flex-start">
<div className={styles.content}>
<div>
<Localized id="profile-account-download-comments-title">
<div className={styles.title}>Download my comment history</div>
</Localized>
@@ -20,6 +20,7 @@ it("renders correctly", () => {
label: "reaction",
icon: "icon",
},
media: null,
story: {
metadata: {
title: "Story Title",
@@ -28,6 +28,7 @@ export interface HistoryCommentProps {
};
conversationURL: string;
onGotoConversation: (e: React.MouseEvent) => void;
media: React.ReactNode;
}
const HistoryComment: FunctionComponent<HistoryCommentProps> = (props) => {
@@ -53,13 +54,16 @@ const HistoryComment: FunctionComponent<HistoryCommentProps> = (props) => {
<InReplyTo username={props.parentAuthorName} />
</div>
)}
<div className={styles.content}>
{props.body && (
<HTMLContent className={CLASSES.myComment.content}>
{props.body}
</HTMLContent>
)}
</div>
<HorizontalGutter>
<div className={styles.content}>
{props.body && (
<HTMLContent className={CLASSES.myComment.content}>
{props.body}
</HTMLContent>
)}
</div>
{props.media}
</HorizontalGutter>
</div>
<Flex
direction="row"
@@ -14,6 +14,7 @@ import { HistoryCommentContainer_comment as CommentData } from "coral-stream/__g
import { HistoryCommentContainer_settings as SettingsData } from "coral-stream/__generated__/HistoryCommentContainer_settings.graphql";
import { HistoryCommentContainer_story as StoryData } from "coral-stream/__generated__/HistoryCommentContainer_story.graphql";
import MediaSectionContainer from "../../Comments/Comment/MediaSection";
import HistoryComment from "./HistoryComment";
interface Props {
@@ -54,6 +55,12 @@ const HistoryCommentContainer: FunctionComponent<Props> = (props) => {
props.comment.id
)}
onGotoConversation={handleGotoConversation}
media={
<MediaSectionContainer
comment={props.comment}
settings={props.settings}
/>
}
/>
);
};
@@ -71,6 +78,7 @@ const enhanced = withSetCommentIDMutation(
label
icon
}
...MediaSectionContainer_settings
}
`,
comment: graphql`
@@ -79,6 +87,7 @@ const enhanced = withSetCommentIDMutation(
body
createdAt
replyCount
...MediaSectionContainer_comment
parent {
author {
username
@@ -29,15 +29,17 @@ exports[`renders correctly 1`] = `
>
2018-07-06T18:24:00.000Z
</TimeStamp>
<div
className="HistoryComment-content"
>
<HTMLContent
className="coral coral-myComment-content"
<ForwardRef(forwardRef)>
<div
className="HistoryComment-content"
>
Hello World
</HTMLContent>
</div>
<HTMLContent
className="coral coral-myComment-content"
>
Hello World
</HTMLContent>
</div>
</ForwardRef(forwardRef)>
</div>
<ForwardRef(forwardRef)
alignItems="center"
@@ -114,7 +114,9 @@ exports[`renders comment stream 1`] = `
<div
className="Box-root HorizontalGutter-root PostCommentFormFake-root HorizontalGutter-full"
>
<div>
<div
className="PostCommentFormFake-rteContainer"
>
<div>
<div
className="$root content placeholder toolbar container"
@@ -122,7 +124,7 @@ exports[`renders comment stream 1`] = `
onFocus={[Function]}
>
<div
className="RTE.module-contentEditableContainer coral coral-rte-container"
className="RTE.module-contentEditableContainer RTE-container coral coral-rte-container"
>
<div
aria-placeholder="Post a comment"
@@ -405,15 +407,19 @@ exports[`renders comment stream 1`] = `
data-testid="featuredComment-comment-0"
>
<div
className="HTMLContent-root FeaturedCommentContainer-body coral coral-content coral-featuredComment-content"
dangerouslySetInnerHTML={
Object {
"__html": "Joining Too",
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<div
className="HTMLContent-root FeaturedCommentContainer-body coral coral-content coral-featuredComment-content"
dangerouslySetInnerHTML={
Object {
"__html": "Joining Too",
}
}
}
/>
/>
</div>
<div
className="Box-root Flex-root coral coral-featuredComment-authorBar Flex-flex Flex-alignCenter Flex-directionRow Box-mt-4"
className="Box-root Flex-root coral coral-featuredComment-authorBar Flex-flex Flex-alignCenter Flex-directionRow Box-mt-3"
>
<div
className="Popover-root"
@@ -541,15 +547,19 @@ exports[`renders comment stream 1`] = `
data-testid="featuredComment-comment-1"
>
<div
className="HTMLContent-root FeaturedCommentContainer-body coral coral-content coral-featuredComment-content"
dangerouslySetInnerHTML={
Object {
"__html": "What's up?",
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<div
className="HTMLContent-root FeaturedCommentContainer-body coral coral-content coral-featuredComment-content"
dangerouslySetInnerHTML={
Object {
"__html": "What's up?",
}
}
}
/>
/>
</div>
<div
className="Box-root Flex-root coral coral-featuredComment-authorBar Flex-flex Flex-alignCenter Flex-directionRow Box-mt-4"
className="Box-root Flex-root coral coral-featuredComment-authorBar Flex-flex Flex-alignCenter Flex-directionRow Box-mt-3"
>
<div
className="Popover-root"
File diff suppressed because it is too large Load Diff
@@ -296,168 +296,177 @@ exports[`post a reply: open reply form 1`] = `
</div>
</div>
</div>
<form
autoComplete="off"
className="coral coral-createReplyComment"
id="comments-replyCommentForm-form-comment-with-deepest-replies-3"
onSubmit={[Function]}
>
<div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
className="coral coral-createReplyComment"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-half"
<form
autoComplete="off"
id="comments-postCommentForm-form"
onSubmit={[Function]}
>
<div>
<label
className="AriaInfo-root"
htmlFor="comments-replyCommentForm-rte-comment-with-deepest-replies-3"
>
Write a reply
</label>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<div
className="Box-root Flex-root ReplyTo-root coral coral-createReplyComment-replyTo Flex-flex Flex-alignCenter"
className="CommentForm-commentFormBox"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
reply
</i>
<span>
 
</span>
<span
className="ReplyTo-text coral coral-createReplyComment-replyToText"
>
Replying to
<span
className="ReplyTo-username coral coral-createReplyComment-replyToUsername"
>
Markus
</span>
</span>
</div>
<div>
<div
className="$root content placeholder toolbar container"
onBlur={[Function]}
onFocus={[Function]}
className="Box-root HorizontalGutter-root HorizontalGutter-half"
>
<div
className="RTE.module-contentEditableContainer coral coral-rte-container"
<label
className="AriaInfo-root"
htmlFor="comments-replyCommentForm-rte-comment-with-deepest-replies-3"
>
<div
aria-placeholder="Write a reply"
className="RTE.module-contentEditable coral coral-rte-content ReplyCommentForm-rteContent RTE-content"
id="comments-replyCommentForm-rte-comment-with-deepest-replies-3"
/>
<div
Write a reply
</label>
<div
className="Box-root Flex-root ReplyTo-root coral coral-createReplyComment-replyTo Flex-flex Flex-alignCenter"
>
<i
aria-hidden="true"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
className="Icon-root Icon-sm"
>
Write a reply
</div>
reply
</i>
<span>
 
</span>
<span
className="ReplyTo-text coral coral-createReplyComment-replyToText"
>
Replying to
<span
className="ReplyTo-username coral coral-createReplyComment-replyToUsername"
>
Markus
</span>
</span>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
<div>
<div>
<div
className="$root content placeholder toolbar container"
onBlur={[Function]}
onFocus={[Function]}
>
format_bold
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_italic
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
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
className="RTE.module-contentEditableContainer RTE-container coral coral-rte-container"
>
<div
aria-placeholder="Write a reply"
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
id="comments-replyCommentForm-rte-comment-with-deepest-replies-3"
/>
<div
aria-hidden="true"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
>
Write a reply
</div>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_bold
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_italic
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
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>
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd gutter Flex-spacing-1"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorMono Button-variantOutline Button-uppercase coral coral-createReplyComment-cancel"
data-color="mono"
data-variant="outline"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Cancel
</button>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorStream Button-variantRegular Button-uppercase Button-disabled coral coral-createReplyComment-submit"
data-color="stream"
data-variant="regular"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Submit
</button>
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-halfItemGutter Flex-justifyFlexEnd Flex-directionRow gutter"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorMono Button-variantOutline Button-uppercase coral coral-createReplyComment-cancel"
data-color="mono"
data-variant="outline"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Cancel
</button>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorStream Button-variantRegular Button-uppercase Button-disabled coral coral-createReplyComment-submit"
data-color="stream"
data-variant="regular"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Submit
</button>
</div>
</form>
</div>
</form>
</div>
</div>
</div>
`;
@@ -252,168 +252,177 @@ exports[`post a reply: open reply form 1`] = `
</div>
</div>
</div>
<form
autoComplete="off"
className="coral coral-createReplyComment"
id="comments-replyCommentForm-form-comment-0"
onSubmit={[Function]}
>
<div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
className="coral coral-createReplyComment"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-half"
<form
autoComplete="off"
id="comments-postCommentForm-form"
onSubmit={[Function]}
>
<div>
<label
className="AriaInfo-root"
htmlFor="comments-replyCommentForm-rte-comment-0"
>
Write a reply
</label>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<div
className="Box-root Flex-root ReplyTo-root coral coral-createReplyComment-replyTo Flex-flex Flex-alignCenter"
className="CommentForm-commentFormBox"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
reply
</i>
<span>
 
</span>
<span
className="ReplyTo-text coral coral-createReplyComment-replyToText"
>
Replying to
<span
className="ReplyTo-username coral coral-createReplyComment-replyToUsername"
>
Markus
</span>
</span>
</div>
<div>
<div
className="$root content placeholder toolbar container"
onBlur={[Function]}
onFocus={[Function]}
className="Box-root HorizontalGutter-root HorizontalGutter-half"
>
<div
className="RTE.module-contentEditableContainer coral coral-rte-container"
<label
className="AriaInfo-root"
htmlFor="comments-replyCommentForm-rte-comment-0"
>
<div
aria-placeholder="Write a reply"
className="RTE.module-contentEditable coral coral-rte-content ReplyCommentForm-rteContent RTE-content"
id="comments-replyCommentForm-rte-comment-0"
/>
<div
Write a reply
</label>
<div
className="Box-root Flex-root ReplyTo-root coral coral-createReplyComment-replyTo Flex-flex Flex-alignCenter"
>
<i
aria-hidden="true"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
className="Icon-root Icon-sm"
>
Write a reply
</div>
reply
</i>
<span>
 
</span>
<span
className="ReplyTo-text coral coral-createReplyComment-replyToText"
>
Replying to
<span
className="ReplyTo-username coral coral-createReplyComment-replyToUsername"
>
Markus
</span>
</span>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
<div>
<div>
<div
className="$root content placeholder toolbar container"
onBlur={[Function]}
onFocus={[Function]}
>
format_bold
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_italic
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
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
className="RTE.module-contentEditableContainer RTE-container coral coral-rte-container"
>
<div
aria-placeholder="Write a reply"
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
id="comments-replyCommentForm-rte-comment-0"
/>
<div
aria-hidden="true"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
>
Write a reply
</div>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_bold
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_italic
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
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>
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd gutter Flex-spacing-1"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorMono Button-variantOutline Button-uppercase coral coral-createReplyComment-cancel"
data-color="mono"
data-variant="outline"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Cancel
</button>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorStream Button-variantRegular Button-uppercase Button-disabled coral coral-createReplyComment-submit"
data-color="stream"
data-variant="regular"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Submit
</button>
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-halfItemGutter Flex-justifyFlexEnd Flex-directionRow gutter"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorMono Button-variantOutline Button-uppercase coral coral-createReplyComment-cancel"
data-color="mono"
data-variant="outline"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Cancel
</button>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorStream Button-variantRegular Button-uppercase Button-disabled coral coral-createReplyComment-submit"
data-color="stream"
data-variant="regular"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Submit
</button>
</div>
</form>
</div>
</form>
</div>
</div>
</div>
`;
@@ -76,7 +76,9 @@ exports[`renders comment stream with community guidelines 1`] = `
<div
className="Box-root HorizontalGutter-root PostCommentFormFake-root HorizontalGutter-full"
>
<div>
<div
className="PostCommentFormFake-rteContainer"
>
<div>
<div
className="$root content placeholder toolbar container"
@@ -84,7 +86,7 @@ exports[`renders comment stream with community guidelines 1`] = `
onFocus={[Function]}
>
<div
className="RTE.module-contentEditableContainer coral coral-rte-container"
className="RTE.module-contentEditableContainer RTE-container coral coral-rte-container"
>
<div
aria-placeholder="Post a comment"
@@ -330,127 +330,137 @@ exports[`renders message box when logged in 1`] = `
}
/>
</div>
<form
autoComplete="off"
id="comments-postCommentForm-form"
onSubmit={[Function]}
<div
className="coral coral-createComment"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
<form
autoComplete="off"
id="comments-postCommentForm-form"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-half"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<label
className="AriaInfo-root"
htmlFor="comments-postCommentForm-field"
<div
className="CommentForm-commentFormBox"
>
Post a comment
</label>
<div>
<div
className="$root content placeholder toolbar container"
onBlur={[Function]}
onFocus={[Function]}
className="Box-root HorizontalGutter-root HorizontalGutter-half"
>
<div
className="RTE.module-contentEditableContainer coral coral-rte-container"
<label
className="AriaInfo-root"
htmlFor="comments-postCommentForm-field"
>
<div
aria-placeholder="Post a comment"
className="RTE.module-contentEditable coral coral-rte-content PostCommentForm-rteBorderless RTE-content"
id="comments-postCommentForm-field"
/>
<div
aria-hidden="true"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
>
Post a comment
Post a comment
</label>
<div>
<div>
<div
className="$root content placeholder toolbar container"
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE.module-contentEditableContainer RTE-container coral coral-rte-container"
>
<div
aria-placeholder="Post a comment"
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
id="comments-postCommentForm-field"
/>
<div
aria-hidden="true"
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
>
Post a comment
</div>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_bold
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_italic
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
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>
</div>
<div
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_bold
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
format_italic
</i>
</button>
<button
className="Button.module-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
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>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignFlexEnd Flex-directionColumn"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorStream Button-variantRegular Button-uppercase Button-disabled coral coral-createComment-submit"
data-color="stream"
data-variant="regular"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd gutter Flex-spacing-1"
>
Submit
</button>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorStream Button-variantRegular Button-uppercase Button-disabled coral coral-createComment-submit"
data-color="stream"
data-variant="regular"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Submit
</button>
</div>
</div>
</div>
</form>
</form>
</div>
</div>
<div
className="Box-root HorizontalGutter-root StreamContainer-tabBarContainer HorizontalGutter-spacing-4"
@@ -666,7 +676,9 @@ exports[`renders message box when not logged in 1`] = `
<div
className="Box-root HorizontalGutter-root PostCommentFormFake-root HorizontalGutter-full"
>
<div>
<div
className="PostCommentFormFake-rteContainer"
>
<div>
<div
className="$root content placeholder toolbar container"
@@ -674,7 +686,7 @@ exports[`renders message box when not logged in 1`] = `
onFocus={[Function]}
>
<div
className="RTE.module-contentEditableContainer coral coral-rte-container"
className="RTE.module-contentEditableContainer RTE-container coral coral-rte-container"
>
<div
aria-placeholder="Post a comment"
@@ -114,7 +114,9 @@ exports[`renders comment stream 1`] = `
<div
className="Box-root HorizontalGutter-root PostCommentFormFake-root HorizontalGutter-full"
>
<div>
<div
className="PostCommentFormFake-rteContainer"
>
<div>
<div
className="$root content placeholder toolbar container"
@@ -122,7 +124,7 @@ exports[`renders comment stream 1`] = `
onFocus={[Function]}
>
<div
className="RTE.module-contentEditableContainer coral coral-rte-container"
className="RTE.module-contentEditableContainer RTE-container coral coral-rte-container"
>
<div
aria-placeholder="Post a comment"

Some files were not shown because too many files have changed in this diff Show More