+
+ );
+ }
+
+ render() {
+ const {open, onCancel} = this.props;
+ const {step} = this.state;
+ return (
+
+ );
+ }
+}
+
+SuspendUserDialog.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onCancel: PropTypes.func.isRequired,
+ onPerform: PropTypes.func.isRequired,
+ username: PropTypes.string,
+ userId: PropTypes.string,
+ organizationName: PropTypes.string,
+};
+
+export default SuspendUserDialog;
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/styles.css b/client/coral-admin/src/containers/ModerationQueue/components/styles.css
index 966a37526..52800fe43 100644
--- a/client/coral-admin/src/containers/ModerationQueue/components/styles.css
+++ b/client/coral-admin/src/containers/ModerationQueue/components/styles.css
@@ -424,6 +424,17 @@ span {
top: 7px;
}
+.username {
+ color: blue;
+ text-decoration: underline;
+ padding: 5px;
+ cursor: pointer;
+
+ &:hover {
+ background-color: rgba(255, 0, 0, .1);
+ }
+}
+
.external {
font-size: .7em;
text-decoration: none;
diff --git a/client/coral-admin/src/graphql/mutations/index.js b/client/coral-admin/src/graphql/mutations/index.js
index 16c391907..3aef68ebc 100644
--- a/client/coral-admin/src/graphql/mutations/index.js
+++ b/client/coral-admin/src/graphql/mutations/index.js
@@ -2,6 +2,7 @@ import {graphql} from 'react-apollo';
import SET_USER_STATUS from './setUserStatus.graphql';
import SET_COMMENT_STATUS from './setCommentStatus.graphql';
import SUSPEND_USER from './suspendUser.graphql';
+import REJECT_USERNAME from './rejectUsername.graphql';
export const banUser = graphql(SET_USER_STATUS, {
props: ({mutate}) => ({
@@ -32,11 +33,22 @@ export const setUserStatus = graphql(SET_USER_STATUS, {
export const suspendUser = graphql(SUSPEND_USER, {
props: ({mutate}) => ({
- suspendUser: ({userId, message}) => {
+ suspendUser: (input) => {
return mutate({
variables: {
- userId,
- message
+ input,
+ },
+ });
+ }
+ })
+});
+
+export const rejectUsername = graphql(REJECT_USERNAME, {
+ props: ({mutate}) => ({
+ rejectUsername: (input) => {
+ return mutate({
+ variables: {
+ input,
},
refetchQueries: ['Users']
});
diff --git a/client/coral-admin/src/graphql/mutations/rejectUsername.graphql b/client/coral-admin/src/graphql/mutations/rejectUsername.graphql
new file mode 100644
index 000000000..c07887649
--- /dev/null
+++ b/client/coral-admin/src/graphql/mutations/rejectUsername.graphql
@@ -0,0 +1,7 @@
+mutation rejectUsername($input: RejectUsernameInput!) {
+ rejectUsername(input: $input) {
+ errors {
+ translation_key
+ }
+ }
+}
diff --git a/client/coral-admin/src/graphql/mutations/suspendUser.graphql b/client/coral-admin/src/graphql/mutations/suspendUser.graphql
index 0d56a3cc0..f6ab2426f 100644
--- a/client/coral-admin/src/graphql/mutations/suspendUser.graphql
+++ b/client/coral-admin/src/graphql/mutations/suspendUser.graphql
@@ -1,5 +1,5 @@
-mutation suspendUser($userId: ID!, $message: String) {
- suspendUser(id: $userId, message: $message) {
+mutation suspendUser($input: SuspendUserInput!) {
+ suspendUser(input: $input) {
errors {
translation_key
}
diff --git a/client/coral-admin/src/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js
index f26c8464e..ae94d70c3 100644
--- a/client/coral-admin/src/graphql/queries/index.js
+++ b/client/coral-admin/src/graphql/queries/index.js
@@ -4,6 +4,7 @@ import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
import MOD_QUEUE_LOAD_MORE from './loadMore.graphql';
import MOD_USER_FLAGGED_QUERY from './modUserFlaggedQuery.graphql';
import METRICS from './metricsQuery.graphql';
+import USER_DETAIL from './userDetail.graphql';
import GET_QUEUE_COUNTS from './getQueueCounts.graphql';
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
@@ -95,6 +96,14 @@ export const modQueueResort = (id, fetchMore) => (sort) => {
});
};
+export const getUserDetail = graphql(USER_DETAIL, {
+ options: ({id}) => {
+ return {
+ variables: {author_id: id}
+ };
+ }
+});
+
export const getQueueCounts = graphql(GET_QUEUE_COUNTS, {
options: ({params: {id = null}}) => {
return {
diff --git a/client/coral-admin/src/graphql/queries/modQueueQuery.graphql b/client/coral-admin/src/graphql/queries/modQueueQuery.graphql
index 80dec5ff7..6bfcf81ec 100644
--- a/client/coral-admin/src/graphql/queries/modQueueQuery.graphql
+++ b/client/coral-admin/src/graphql/queries/modQueueQuery.graphql
@@ -62,4 +62,7 @@ query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
asset_id: $asset_id,
statuses: [NONE, PREMOD]
})
+ settings {
+ organizationName
+ }
}
diff --git a/client/coral-admin/src/graphql/queries/userDetail.graphql b/client/coral-admin/src/graphql/queries/userDetail.graphql
new file mode 100644
index 000000000..c375b80ff
--- /dev/null
+++ b/client/coral-admin/src/graphql/queries/userDetail.graphql
@@ -0,0 +1,13 @@
+query UserDetail ($author_id: ID!) {
+ user(id: $author_id) {
+ id
+ username
+ created_at
+ profiles {
+ id
+ provider
+ }
+ }
+ totalComments: commentCount(query: {author_id: $author_id})
+ rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
+}
diff --git a/client/coral-admin/src/reducers/auth.js b/client/coral-admin/src/reducers/auth.js
index 1e080d37e..a60d73cec 100644
--- a/client/coral-admin/src/reducers/auth.js
+++ b/client/coral-admin/src/reducers/auth.js
@@ -4,7 +4,6 @@ import * as actions from '../constants/auth';
const initialState = Map({
loggedIn: false,
user: null,
- isAdmin: false,
loginError: null,
loginMaxExceeded: false,
passwordRequestSuccess: null
@@ -24,7 +23,6 @@ export default function auth (state = initialState, action) {
return state
.set('loggedIn', true)
.set('loadingUser', false)
- .set('isAdmin', action.isAdmin)
.set('user', action.user);
case actions.LOGOUT:
return initialState;
diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js
index 8f51cea6b..4bcd82d47 100644
--- a/client/coral-admin/src/reducers/moderation.js
+++ b/client/coral-admin/src/reducers/moderation.js
@@ -1,14 +1,22 @@
-import {Map} from 'immutable';
+import {fromJS, Map} from 'immutable';
import * as actions from '../constants/moderation';
-const initialState = Map({
+const initialState = fromJS({
singleView: false,
modalOpen: false,
- user: Map({}),
+ user: {},
commentId: null,
commentStatus: null,
+ userDetailId: null,
banDialog: false,
- shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show'
+ shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
+ suspendUserDialog: {
+ show: false,
+ userId: null,
+ username: '',
+ commentId: null,
+ commentStatus: '',
+ },
});
export default function moderation (state = initialState, action) {
@@ -26,6 +34,20 @@ export default function moderation (state = initialState, action) {
showRejectedNote: action.showRejectedNote,
banDialog: true
});
+ case actions.SHOW_SUSPEND_USER_DIALOG:
+ return state
+ .mergeDeep({
+ suspendUserDialog: {
+ show: true,
+ userId: action.userId,
+ username: action.username,
+ commentId: action.commentId,
+ commentStatus: action.commentStatus,
+ }
+ });
+ case actions.HIDE_SUSPEND_USER_DIALOG:
+ return state
+ .setIn(['suspendUserDialog', 'show'], false);
case actions.SET_ACTIVE_TAB:
return state
.set('activeTab', action.activeTab);
@@ -38,6 +60,10 @@ export default function moderation (state = initialState, action) {
case actions.HIDE_SHORTCUTS_NOTE:
return state
.set('shortcutsNoteVisible', 'hide');
+ case actions.VIEW_USER_DETAIL:
+ return state.set('userDetailId', action.userId);
+ case actions.HIDE_USER_DETAIL:
+ return state.set('userDetailId', null);
default :
return state;
}
diff --git a/client/coral-admin/src/services/notification.js b/client/coral-admin/src/services/notification.js
new file mode 100644
index 000000000..01b256df0
--- /dev/null
+++ b/client/coral-admin/src/services/notification.js
@@ -0,0 +1,28 @@
+import translations from 'coral-admin/src/translations';
+import I18n from 'coral-framework/modules/i18n/i18n';
+import {toast} from 'react-toastify';
+
+const lang = new I18n(translations);
+
+export function success(msg) {
+ return toast(msg, {type: 'success'});
+}
+
+export function error(msg) {
+ return toast(msg, {type: 'error'});
+}
+
+export function info(msg) {
+ return toast(msg, {type: 'info'});
+}
+
+export function showMutationErrors(err) {
+ const errors = Array.isArray(err) ? err : [err];
+ errors.forEach((err) => {
+ console.error(err);
+ toast(
+ err.translation_key ? lang.t(`errors.${err.translation_key}`) : err,
+ {type: 'error'}
+ );
+ });
+}
diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json
index 68a3b93d5..68072f649 100644
--- a/client/coral-admin/src/translations.json
+++ b/client/coral-admin/src/translations.json
@@ -1,7 +1,7 @@
{
"en": {
"errors": {
- "NOT_AUTHORIZED": "Your username or password is not recognized by our system.",
+ "NOT_AUTHORIZED": "You are not authorized to perform this action.",
"LOGIN_MAXIMUM_EXCEEDED": "You have made too many unsuccessful password attempts. Please wait."
},
"community": {
@@ -10,6 +10,7 @@
"newsroom_role": "Newsroom Role",
"admin": "Administrator",
"moderator": "Moderator",
+ "staff": "Staff",
"role": "Select role...",
"no-results": "No users found with that user name or email address. They're hiding!",
"status": "Status",
@@ -106,6 +107,9 @@
"include-text": "Include your text here.",
"enable-premod-links": "Pre-Moderate Comments Containing Links",
"enable-premod-links-text": "Moderators must approve any comment containing a link before its published.",
+ "edit-comment-timeframe-heading": "Edit Comment Timeframe",
+ "edit-comment-timeframe-text-pre": "Commenters will have",
+ "edit-comment-timeframe-text-post": "seconds to edit their comments.",
"comment-settings": "Settings",
"embed-comment-stream": "Embed Stream",
"banned-word-text": "Comments which contain these words or phrases (not case-sensitive) will be automatically removed from the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
@@ -140,19 +144,30 @@
"yes_ban_user": "Yes, Ban User"
},
"suspenduser": {
- "title": "Suspend a user",
- "title_0": "We noticed you rejected a username",
- "description_0": "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
- "title_1": "Notify the user of their temporary suspension",
- "description_1": "Suspending this user will temporarily disable their account and hide all of their comments on the site.",
+ "title_suspend": "Suspend User",
+ "description_suspend": "You are suspending {0}. This comment will go to the Rejected queue, and {0} will not be allowed to like, report, reply or post until the suspension time is complete.",
+ "select_duration": "Select suspension duration",
+ "title_reject": "We noticed you rejected a username",
+ "description_reject": "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
+
+ "title_notify": "Notify the user of their temporary suspension",
+ "description_notify": "Suspending this user will temporarily disable their account and hide all of their comments on the site.",
"no_cancel": "No, cancel",
"yes_suspend": "Yes, suspend",
"send": "Send",
"bio": "bio",
"username": "username",
"email_subject": "Your account has been suspended",
- "email": "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns.",
- "write_message": "Write a message"
+ "email_message_reject": "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns.",
+ "email_message_suspend": "Dear {0},\n\nIn accordance with {1}’s community guidelines, your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation {2}.",
+ "write_message": "Write a message",
+ "one_hour": "1 hour",
+ "hours": "{0} hours",
+ "days": "{0} days",
+ "suspend_user": "Suspend User",
+ "cancel": "Cancel",
+ "error_email_message_empty": "You must specify an E-Mail message.",
+ "notify_suspend_until": "User {0} has been temporarily suspended. This suspension will automatically end {1}."
},
"dashboard": {
"next-update": "{0} minutes until next update.",
@@ -193,6 +208,7 @@
"newsroom_role": "Rol en la redacción",
"admin": "Administradora",
"moderator": "Moderadora",
+ "staff": "Miembro",
"role": "Seleccionar rol...",
"no-results": "No se encontraron usuarixs con ese nombre de usuario o e-mail.",
"status": "Estado",
@@ -217,19 +233,24 @@
"yes_ban_user": "Si, Suspendan el usuario"
},
"suspenduser": {
- "title": "Suspendiendo un usuario",
- "title_0": "Esta queriendo suspender un usuario?",
- "description_0": "Le gustaria suspender a esta usuaria temporarianmente por su nombre de usuario? Si lo hace sus comentarios serán escondidos temporariamente hasta que puedan reescribir su nombre de usuario.",
- "title_1": "Enviarle una nota al usuario sobre su cuenta suspendida",
- "description_1": "Si suspende a este usuario, su cuenta va a ser deshabilitada y todos sus comentarios escondidos del sitio.",
+ "title_suspend": "Suspender Usuario",
+ "title_reject": "Esta queriendo suspender un usuario?",
+ "description_reject": "Le gustaria suspender a esta usuaria temporarianmente por su nombre de usuario? Si lo hace sus comentarios serán escondidos temporariamente hasta que puedan reescribir su nombre de usuario.",
+ "title_notify": "Enviarle una nota al usuario sobre su cuenta suspendida",
+ "description_notify": "Si suspende a este usuario, su cuenta va a ser deshabilitada y todos sus comentarios escondidos del sitio.",
"no_cancel": "No, cancelar",
"yes_suspend": "Si, suspender",
"send": "Enviar",
"username": "nombre de usuario",
"email_subject": "Su cuenta ha sido suspendida temporariamente",
- "email": "Otra persona de la comunidad recientemente marcó su nombre de usuario para ser revisado. Por su contenido, el nombre de usuario ha sido rechazado. Esto quiere decir que no puede comentar, gustar o marcar contenido hasta que modifique su nombre de usuario. Por favor, envienos un correo a moderator@newsorg.com si tiene alguna pregunta o preocupación",
+ "email_message_reject": "Otra persona de la comunidad recientemente marcó su nombre de usuario para ser revisado. Por su contenido, el nombre de usuario ha sido rechazado. Esto quiere decir que no puede comentar, gustar o marcar contenido hasta que modifique su nombre de usuario. Por favor, envienos un correo a moderator@newsorg.com si tiene alguna pregunta o preocupación",
"write_message": "Escribir un mensaje",
- "loading": "Cargando resultados"
+ "loading": "Cargando resultados",
+ "hour": "una hora",
+ "hours": "{0} horas",
+ "days": "{0} días",
+ "suspend_user": "Suspender",
+ "cancel": "Cancelar"
},
"modqueue": {
"all": "todos",
@@ -294,6 +315,9 @@
"embed-comment-stream": "Colocar Hilo de Comentarios",
"enable-premod-links": "Pre-Moderar Commentarios que contienen Enlaces",
"enable-premod-links-text": "Los y las Moderadoras deben aprobar cualquier comentario que contengan links antes de su publicación.",
+ "edit-comment-timeframe-heading": "Editar Tiempo de Comentario",
+ "edit-comment-timeframe-text-pre": "Los comentaristas tendrán",
+ "edit-comment-timeframe-text-post": "segundos para editar sus comentarios.",
"wordlist": "Palabras Suspendidas y Sospechosas",
"banned-word-text": "Comentarios que contengan estas palabras o frases, no separadas por comas y en mayusculas o minusuculas, serán automaticamente marcadas para separar los comentarios publicados.",
"suspect-word-text": "Comentarios que contengan estas palabras o frases, considerando mayusculas y minusculas, serán automaticamente destacadas en los comentarios publicados. Escribir una palabra y apretar Enter o Tabulador para agergarla. Opcionalmente pegar una lista separada por coma.",
diff --git a/client/coral-embed-stream/public/samplearticle.html b/client/coral-embed-stream/public/samplearticle.html
deleted file mode 100644
index 454cb249c..000000000
--- a/client/coral-embed-stream/public/samplearticle.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
Lorem ipsum
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut lobortis sollicitudin eros a ornare. Curabitur dignissim vestibulum massa non rhoncus. Cras laoreet ante vel nunc hendrerit, ac imperdiet neque egestas. Suspendisse aliquet iaculis fermentum. Pellentesque interdum nec elit sed tincidunt. Donec volutpat, tellus posuere laoreet consequat, mi lacus laoreet massa, sed vehicula mauris velit non lectus. Integer non enim nec neque congue faucibus porttitor sit amet dui.
-
Nunc pharetra orci id diam feugiat, vitae rutrum magna efficitur. Morbi porttitor blandit lorem, et facilisis tellus luctus at. Morbi tincidunt eget nisl id placerat. Nullam consectetur quam vel mauris lacinia, non consectetur est faucibus. Duis cursus auctor nulla nec sagittis. Aenean sem erat, ultrices a hendrerit consectetur, accumsan non lorem. Integer ac neque sed magna sodales vulputate at quis neque. Praesent eget ornare lacus. Donec ultricies, dolor eget commodo faucibus, arcu velit ullamcorper tellus, in cursus tellus elit sed urna. Suspendisse in consequat magna. Duis vel ullamcorper tortor, vel cursus libero. Proin et nisi luctus ligula faucibus luctus. Morbi pulvinar, justo ac feugiat elementum, libero tellus congue justo, pharetra ultrices felis felis id leo. Integer mattis quam tempus libero porta, ac pretium ligula elementum.
-
-
-
-
-
-
diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js
index 79387287e..3ebb82f39 100644
--- a/client/coral-embed-stream/src/components/Comment.js
+++ b/client/coral-embed-stream/src/components/Comment.js
@@ -25,7 +25,7 @@ import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
import {getEditableUntilDate} from './util';
import styles from './Comment.css';
-const isStaff = (tags) => !!tags.filter((i) => i.tag.name === 'STAFF').length;
+const isStaff = (tags) => tags.some((i) => i.tag.name === 'STAFF');
// hold actions links (e.g. Reply) along the comment footer
const ActionButton = ({children}) => {
diff --git a/client/coral-embed-stream/src/components/Embed.js b/client/coral-embed-stream/src/components/Embed.js
index 0d26cf740..5f27293d7 100644
--- a/client/coral-embed-stream/src/components/Embed.js
+++ b/client/coral-embed-stream/src/components/Embed.js
@@ -1,6 +1,7 @@
import React from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-framework/translations';
+import {can} from 'coral-framework/services/perms';
const lang = new I18n(translations);
import {TabBar, Tab, TabContent, Button} from 'coral-ui';
@@ -9,7 +10,6 @@ import Stream from '../containers/Stream';
import Count from 'coral-plugin-comment-count/CommentCount';
import UserBox from 'coral-sign-in/components/UserBox';
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
-import RestrictedContent from 'coral-framework/components/RestrictedContent';
import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer';
export default class Embed extends React.Component {
@@ -38,7 +38,7 @@ export default class Embed extends React.Component {
render () {
const {activeTab, logout, viewAllComments, commentId} = this.props;
const {asset: {totalCommentCount}} = this.props.root;
- const {loggedIn, isAdmin, user} = this.props.auth;
+ const {loggedIn, user} = this.props.auth;
const userBox = ;
@@ -48,7 +48,7 @@ export default class Embed extends React.Component {
{lang.t('myProfile')}
- Configure Stream
+ Configure Stream
{
commentId &&
@@ -68,10 +68,8 @@ export default class Embed extends React.Component {
-
- { loggedIn ? userBox : null }
-
-
+ { loggedIn ? userBox : null }
+
diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js
index 81e033ee5..fa8deeee3 100644
--- a/client/coral-embed-stream/src/components/Stream.js
+++ b/client/coral-embed-stream/src/components/Stream.js
@@ -9,10 +9,16 @@ import {ModerationLink} from 'coral-plugin-moderation';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
-import SuspendedAccount from 'coral-framework/components/SuspendedAccount';
-import RestrictedContent from 'coral-framework/components/RestrictedContent';
+import SuspendedAccount from './SuspendedAccount';
+import RestrictedMessageBox
+ from 'coral-framework/components/RestrictedMessageBox';
+import {can} from 'coral-framework/services/perms';
import ChangeUsernameContainer
from 'coral-sign-in/containers/ChangeUsernameContainer';
+import I18n from 'coral-framework/modules/i18n/i18n';
+import translations from 'coral-framework/translations';
+
+const lang = new I18n(translations);
class Stream extends React.Component {
setActiveReplyBox = (reactKey) => {
@@ -25,7 +31,7 @@ class Stream extends React.Component {
render() {
const {
- root: {asset, asset: {comments}, comment, myIgnoredUsers},
+ root: {asset, asset: {comments}, comment, me},
postComment,
addNotification,
postFlag,
@@ -37,7 +43,7 @@ class Stream extends React.Component {
removeTag,
pluginProps,
ignoreUser,
- auth: {loggedIn, isAdmin, user},
+ auth: {loggedIn, user},
commentCountCache,
editName
} = this.props;
@@ -49,6 +55,7 @@ class Stream extends React.Component {
: comment;
const banned = user && user.status === 'BANNED';
+ const temporarilySuspended = user && user.suspension.until && new Date(user.suspension.until) > new Date();
const hasOlderComments = !!(asset &&
asset.lastComment &&
@@ -58,8 +65,9 @@ class Stream extends React.Component {
const firstCommentDate = asset.comments[0]
? asset.comments[0].created_at
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
- const commentIsIgnored = (comment) =>
- myIgnoredUsers && myIgnoredUsers.includes(comment.user.id);
+ const commentIsIgnored = (comment) => {
+ return me && me.ignoredUsers && me.ignoredUsers.find((u) => u.id === comment.user.id);
+ };
return (
}
{!loggedIn &&
@@ -111,7 +125,7 @@ class Stream extends React.Component {
{loggedIn &&
user &&
}
- {loggedIn && }
+ {loggedIn && }
{/* the highlightedComment is isolated after the user followed a permalink */}
{highlightedComment
@@ -150,8 +164,8 @@ class Stream extends React.Component {
/>