Merge branch 'master' into query-composition-2

Conflicts:
	client/coral-embed-stream/src/Embed.js
	client/coral-embed-stream/src/components/Comment.js
	client/coral-embed-stream/src/index.js
	client/coral-framework/services/store.js
	client/coral-settings/containers/ProfileContainer.js
	graph/resolvers/root_query.js
This commit is contained in:
Chi Vinh Le
2017-04-24 15:24:34 +07:00
47 changed files with 527 additions and 218 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:7.9
FROM node:7.8
# Create app directory
RUN mkdir -p /usr/src/app
+2 -2
View File
@@ -3,12 +3,12 @@ FROM coralproject/talk:latest
# Bundle app source
ONBUILD COPY . /usr/src/app
# At this stage, we need to install the development dependancies again because
# At this stage, we need to install the development dependancies again because
# we need to have webpack available. We then build the new dependancies and
# clear out the development dependancies again. After this we of course need to
# clear out the yarn cache, this saves quite a lot of size.
ONBUILD RUN NODE_ENV=development yarn install --frozen-lockfile && \
NODE_ENV=production cli plugins reconcile && \
NODE_ENV=production yarn build && \
NODE_ENV=production yarn install --production && \
NODE_ENV=production yarn install --production --force && \
yarn cache clean
+2 -2
View File
@@ -174,8 +174,8 @@ and testing purposes.
There are some runtime requirements for running Talk from source:
- [Node](https://nodejs.org/) v7.9 or later
- [Yarn](https://yarnpkg.com/) v0.22.0 or later
- [Node](https://nodejs.org/) ~7.8
- [Yarn](https://yarnpkg.com/) ^0.22.0
_Please be sure to check the versions of these requirements. Incorrect versions
of these may lead to unexpected errors!_
@@ -116,7 +116,7 @@ class ModerationContainer extends Component {
let asset;
if (data.loading) {
if (!('premodCount' in data)) {
return <div><Spinner/></div>;
}
@@ -10,6 +10,7 @@ import FlagBox from './FlagBox';
import CommentType from './CommentType';
import ActionButton from 'coral-admin/src/components/ActionButton';
import BanUserButton from 'coral-admin/src/components/BanUserButton';
import {getActionSummary} from 'coral-framework/utils';
const linkify = new Linkify();
@@ -17,25 +18,27 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations.json';
const lang = new I18n(translations);
const Comment = ({actions = [], ...props}) => {
const links = linkify.getMatches(props.comment.body);
const Comment = ({actions = [], comment, ...props}) => {
const links = linkify.getMatches(comment.body);
const linkText = links ? links.map(link => link.raw) : [];
const actionSummaries = props.comment.action_summaries;
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions = comment.actions && comment.actions.filter(a => a.__typename === 'FlagAction');
return (
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--8dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem}`}>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<span>
{props.comment.user.name}
{comment.user.name}
</span>
<span className={styles.created}>
{timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
{timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
</span>
<BanUserButton user={props.comment.user} onClick={() => props.showBanUserDialog(props.comment.user, props.comment.id, props.comment.status !== 'REJECTED')} />
<BanUserButton user={comment.user} onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status !== 'REJECTED')} />
<CommentType type={props.commentType} />
</div>
{props.comment.user.status === 'banned' ?
{comment.user.status === 'banned' ?
<span className={styles.banned}>
<Icon name='error_outline'/>
{lang.t('comment.banned_user')}
@@ -43,16 +46,16 @@ const Comment = ({actions = [], ...props}) => {
: null}
</div>
<div className={styles.moderateArticle}>
Story: {props.comment.asset.title}
Story: {comment.asset.title}
{!props.currentAsset && (
<Link to={`/admin/moderate/${props.comment.asset.id}`}>Moderate &rarr;</Link>
<Link to={`/admin/moderate/${comment.asset.id}`}>Moderate </Link>
)}
</div>
<div className={styles.itemBody}>
<p className={styles.body}>
<Highlighter
searchWords={[...props.suspectWords, ...props.bannedWords, ...linkText]}
textToHighlight={props.comment.body} />
textToHighlight={comment.body} />
</p>
<div className={styles.sideActions}>
{links ? <span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
@@ -60,16 +63,20 @@ const Comment = ({actions = [], ...props}) => {
{actions.map((action, i) =>
<ActionButton key={i}
type={action}
user={props.comment.user}
acceptComment={() => props.acceptComment({commentId: props.comment.id})}
rejectComment={() => props.rejectComment({commentId: props.comment.id})}
user={comment.user}
acceptComment={() => props.acceptComment({commentId: comment.id})}
rejectComment={() => props.rejectComment({commentId: comment.id})}
/>
)}
</div>
</div>
</div>
</div>
{actionSummaries && <FlagBox actionSummaries={actionSummaries} />}
{
flagActions && flagActions.length
? <FlagBox actions={flagActions} actionSummaries={flagActionSummaries} />
: null
}
</li>
);
};
@@ -83,6 +90,7 @@ Comment.propTypes = {
comment: PropTypes.shape({
body: PropTypes.string.isRequired,
action_summaries: PropTypes.array,
actions: PropTypes.array,
created_at: PropTypes.string.isRequired,
user: PropTypes.shape({
status: PropTypes.string
@@ -53,3 +53,17 @@
font-size: 12px;
}
}
.lessDetail {
display: inline-block;
margin-right: 10px;
}
.subDetail {
font-weight: normal;
color: #888;
span {
color: black;
}
}
@@ -1,6 +1,17 @@
import React, {Component, PropTypes} from 'react';
import {Icon} from 'coral-ui';
import styles from './FlagBox.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations.json';
const lang = new I18n(translations);
const shortReasons = {
'This comment is offensive': lang.t('modqueue.offensive'),
'This looks like an ad/marketing': lang.t('modqueue.spam/ads'),
'This user is impersonating': lang.t('modqueue.impersonating'),
'I don\'t like this username': lang.t('modqueue.dont-like-username'),
'Other': lang.t('modqueue.other')
};
class FlagBox extends Component {
constructor () {
@@ -16,27 +27,50 @@ class FlagBox extends Component {
}));
}
reasonMap = (reason) => {
const shortReason = shortReasons[reason];
// if the short reason isn't found, just return the long one.
return shortReason ? shortReason : reason;
}
render() {
const {props} = this;
const {actionSummaries, actions} = this.props;
const {showDetail} = this.state;
return (
<div className={styles.flagBox}>
<div className={styles.container}>
<div className={styles.header}>
<Icon name='flag'/><h3>Flags ({props.actionSummaries.length}):</h3>
<Icon name='flag'/><h3>Flags ({actionSummaries.length}):</h3>
<ul>
{props.actionSummaries.map((action, i) =>
<li key={i}>{!action.reason ? <i>No reason provided</i> : action.reason} (<strong>{action.count}</strong>)</li>
{actionSummaries.map((action, i) =>
<li key={i} className={styles.lessDetail}>{this.reasonMap(action.reason)} (<strong>{action.count}</strong>)</li>
)}
</ul>
{/* <a onClick={this.toggleDetail} className={styles.moreDetail}>More detail</a>*/}
<a onClick={this.toggleDetail} className={styles.moreDetail}>{showDetail ? lang.t('modqueue.less-detail') : lang.t('modqueue.more-detail')}</a>
</div>
{this.state.showDetail && (<div className={styles.detail}>
<ul>
{props.actionSummaries.map((action, i) =>
<li key={i}>{!action.reason ? <i>No reason provided</i> : action.reason} (<strong>{action.count}</strong>)</li>
)}
</ul>
</div>)}
{showDetail && (
<div className={styles.detail}>
<ul>
{actionSummaries.map((summary, i) => {
const actionList = actions.filter(a => a.reason === summary.reason);
return (
<li key={i}>
{this.reasonMap(summary.reason)} (<strong>{summary.count}</strong>)
<ul>
{
actionList.map((action, j) => <li key={`${i}_${j}`} className={styles.subDetail}><span>{action.user.username}</span> {action.message}</li>)
}
</ul>
</li>
);
})}
</ul>
</div>
)}
</div>
</div>
);
@@ -44,7 +78,14 @@ class FlagBox extends Component {
}
FlagBox.propTypes = {
actionSummaries: PropTypes.array.isRequired
actionSummaries: PropTypes.arrayOf(PropTypes.shape({
reason: PropTypes.string,
count: PropTypes.number
})).isRequired,
actions: PropTypes.arrayOf(PropTypes.shape({
message: PropTypes.string,
user: PropTypes.shape({username: PropTypes.string})
})).isRequired
};
export default FlagBox;
@@ -12,4 +12,19 @@ fragment commentView on Comment {
id
title
}
action_summaries {
count
... on FlagActionSummary {
reason
}
}
actions {
... on FlagAction {
reason
message
user {
username
}
}
}
}
@@ -55,7 +55,7 @@ export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) =>
statuses,
asset_id
},
updateQuery: (oldData, {fetchMoreResult:{data:{comments}}}) => {
updateQuery: (oldData, {fetchMoreResult:{comments}}) => {
return {
...oldData,
[tab]: [
@@ -15,12 +15,6 @@ query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
sort: $sort
}) {
...commentView
action_summaries {
count
... on FlagActionSummary {
reason
}
}
}
rejected: comments(query: {
statuses: [REJECTED],
@@ -1,7 +1,9 @@
import ApolloClient, {addTypename} from 'apollo-client';
import getNetworkInterface from './transport';
import fragmentMatcher from './fragmentMatcher';
export const client = new ApolloClient({
fragmentMatcher,
addTypename: true,
queryTransformer: addTypename,
dataIdFromObject: (result) => {
@@ -0,0 +1,33 @@
import {IntrospectionFragmentMatcher} from 'apollo-client';
// TODO this is a short-term fix
// we need to set up something to query the server for the schema before ApolloClient initialization
// https://github.com/apollographql/apollo-client/issues/1555#issuecomment-295834774
const fm = new IntrospectionFragmentMatcher({
introspectionQueryResultData: {
__schema: {
types: [
{
kind: 'INTERFACE',
name: 'Action',
possibleTypes: [
{name: 'FlagAction'},
{name: 'LikeAction'},
{name: 'DontAgreeAction'}
],
},
{
kind: 'INTERFACE',
name: 'ActionSummary',
possibleTypes: [
{name: 'FlagActionSummary'},
{name: 'LikeActionSummary'},
{name: 'DontAgreeActionSummary'}
],
}
],
},
}
});
export default fm;
+16 -2
View File
@@ -51,7 +51,14 @@
"singleview": "Toggle single comment edit view",
"thismenu": "Open this menu",
"emptyqueue": "No more comments to moderate! You're all caught up. Go have some ☕️",
"showshortcuts": "Show Shortcuts"
"showshortcuts": "Show Shortcuts",
"more-detail": "More detail",
"less-detail": "Less detail",
"dont-like-username": "Don't like username",
"impersonating": "Impersonating",
"offensive": "Offensive",
"spam/ads": "Spam/Ads",
"other": "Other"
},
"comment": {
"flagged": "flagged",
@@ -221,7 +228,14 @@
"shortcuts": "Atajos de teclado",
"close": "Cerrar",
"emptyqueue": "No se encontro ningún usuario. Están escondidos.",
"showshortcuts": "Mostrar atajos"
"showshortcuts": "Mostrar atajos",
"more-detail": "Mas detalle",
"less-detail": "Menos detalle",
"dont-like-username": "No me gusta ese nombre de usuario",
"impersonating": "Suplantación",
"offensive": "Ofensivo",
"spam/ads": "Spam/Propaganda",
"other": "Otros"
},
"comment": {
"flagged": "marcado",
@@ -0,0 +1,16 @@
import React from 'react';
import {Router, Route, browserHistory} from 'react-router';
import Embed from './containers/Embed';
import SignInContainer from 'coral-sign-in/containers/SignInContainer';
const routes = (
<div>
<Route exact path="/embed/stream/login" component={SignInContainer}/>
<Route exact path="*" component={Embed}/>
</div>
);
const AppRouter = () => <Router history={browserHistory} routes={routes} />;
export default AppRouter;
@@ -18,15 +18,14 @@ import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
import FlagComment from 'coral-plugin-flags/FlagComment';
import LikeButton from 'coral-plugin-likes/LikeButton';
import {BestButton, IfUserCanModifyBest, BEST_TAG, commentIsBest, BestIndicator} from 'coral-plugin-best/BestButton';
import {Slot} from 'coral-framework';
import Slot from 'coral-framework/components/Slot';
import LoadMore from './LoadMore';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import {TopRightMenu} from './TopRightMenu';
import {getActionSummary, getTotalActionCount, iPerformedThisAction} from 'coral-framework/utils';
import styles from './Comment.css';
const getActionSummary = (type, comment) => comment.action_summaries
.filter((a) => a.__typename === type)[0];
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF') ;
// hold actions links (e.g. Like, Reply) along the comment footer
@@ -124,9 +123,16 @@ class Comment extends React.Component {
commentIsIgnored,
} = this.props;
const like = getActionSummary('LikeActionSummary', comment);
const flag = getActionSummary('FlagActionSummary', comment);
const dontagree = getActionSummary('DontAgreeActionSummary', comment);
const likeSummary = getActionSummary('LikeActionSummary', comment);
const flagSummary = getActionSummary('FlagActionSummary', comment);
const dontAgreeSummary = getActionSummary('DontAgreeActionSummary', comment);
let myFlag = null;
if (iPerformedThisAction('FlagActionSummary', comment)) {
myFlag = flagSummary.find(s => s.current_user);
} else if (iPerformedThisAction('DontAgreeActionSummary', comment)) {
myFlag = dontAgreeSummary.find(s => s.current_user);
}
let commentClass = parentId ? `reply ${styles.Reply}` : `comment ${styles.Comment}`;
commentClass += comment.id === 'pending' ? ` ${styles.pendingComment}` : '';
@@ -183,8 +189,10 @@ class Comment extends React.Component {
<Content body={comment.body} />
<div className="commentActionsLeft comment__action-container">
<ActionButton>
{/* TODO implmement iPerformedThisAction for the like */}
<LikeButton
like={like}
totalLikes={getTotalActionCount('LikeActionSummary', comment)}
like={likeSummary[0]}
id={comment.id}
postLike={postLike}
deleteAction={deleteAction}
@@ -217,7 +225,8 @@ class Comment extends React.Component {
</ActionButton>
<ActionButton>
<FlagComment
flag={flag && flag.current_user ? flag : dontagree}
flaggedByCurrentUser={!!myFlag}
flag={myFlag}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
@@ -78,7 +78,7 @@ export default class Embed extends React.Component {
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
comments={asset.comments}
ignoredUsers={this.props.data.myIgnoredUsers.map(u => u.id)}
ignoredUsers={this.props.data.myIgnoredUsers ? this.props.data.myIgnoredUsers.map(u => u.id) : []}
auth={this.props.auth}
comment={this.props.data.comment}
commentCountCache={this.props.commentCountCache}
@@ -1,7 +1,7 @@
import React, {PropTypes} from 'react';
import {Button} from 'coral-ui';
import Comment from './Comment';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import SignInContainer from 'coral-sign-in/containers/SignInContainer';
import SuspendedAccount from 'coral-framework/components/SuspendedAccount';
import RestrictedContent from 'coral-framework/components/RestrictedContent';
import ChangeUsernameContainer from 'coral-sign-in/containers/ChangeUsernameContainer';
@@ -16,8 +16,7 @@ class Stream extends React.Component {
setActiveReplyBox = (reactKey) => {
if (!this.props.auth.user) {
const offset = document.getElementById(`c_${reactKey}`).getBoundingClientRect().top - 75;
this.props.showSignInDialog(offset);
this.props.showSignInDialog();
} else {
this.props.setActiveReplyBox(reactKey);
}
@@ -40,7 +39,7 @@ class Stream extends React.Component {
pluginProps,
ignoreUser,
ignoredUsers,
auth: {signInOffset, loggedIn, isAdmin, user},
auth: {loggedIn, isAdmin, user},
comment,
refetch,
commentCountCache,
@@ -104,11 +103,8 @@ class Stream extends React.Component {
</div>
: <p>{asset.settings.closedMessage}</p>
}
{!loggedIn && <SignInContainer
requireEmailConfirmation={asset.settings.requireEmailConfirmation}
refetch={refetch}
offset={signInOffset}/>}
{loggedIn && user && <ChangeUsernameContainer loggedIn={loggedIn} offset={signInOffset} user={user} />}
{!loggedIn && <Button id='coralSignInButton' onClick={this.props.showSignInDialog} full>Sign in to comment</Button>}
{loggedIn && user && <ChangeUsernameContainer loggedIn={loggedIn} user={user} />}
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={isAdmin} />}
{/* the highlightedComment is isolated after the user followed a permalink */}
@@ -17,7 +17,7 @@ import {setCommentCountCache, setActiveReplyBox, viewAllComments} from '../actio
import {setActiveTab} from '../actions/embed';
import * as Stream from './Stream';
const {logout, showSignInDialog, requestConfirmEmail} = authActions;
const {logout, showSignInDialog, requestConfirmEmail, checkLogin} = authActions;
const {addNotification, clearNotification} = notificationActions;
const {fetchAssetSuccess} = assetActions;
@@ -25,6 +25,7 @@ class EmbedContainer extends React.Component {
componentDidMount() {
pym.sendMessage('childReady');
this.props.checkLogin();
}
componentWillUnmount() {
@@ -135,7 +136,7 @@ const STREAM_QUERY = gql`
__typename
...Stream_root
}
${Stream.fragment}
${Stream.fragments.root}
`;
// get the counts of the top-level comments
@@ -271,6 +272,7 @@ const mapDispatchToProps = dispatch =>
fetchAssetSuccess,
addNotification,
clearNotification,
checkLogin,
editName,
setCommentCountCache,
viewAllComments,
+5 -3
View File
@@ -3,16 +3,18 @@ import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import {client} from 'coral-framework/services/client';
import {store, injectReducers} from 'coral-framework/services/store';
import Embed from './containers/Embed';
import reducers from './reducers';
import localStore, {injectReducers} from 'coral-framework/services/store';
import AppRouter from './AppRouter';
injectReducers(reducers);
const store = (window.opener && window.opener.coralStore) ? window.opener.coralStore : localStore;
render(
<ApolloProvider client={client} store={store}>
<Embed />
<AppRouter />
</ApolloProvider>
, document.querySelector('#coralStream')
);
@@ -396,6 +396,7 @@ button.comment__action-button[disabled],
margin-left: 20px;
margin-top: 5px;
width: 75%;
font-size: 16px;
}
/* Close comments */
@@ -475,3 +476,19 @@ button.comment__action-button[disabled],
.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{
width: initial;
}
@media (min-device-width : 300px) and (max-device-width : 420px) {
.commentActionsLeft.comment__action-container .coral-plugin-likes-button-text,
.commentActionsLeft.comment__action-container > div span {
display: none;
}
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button {
visibility: collapse;
margin-left: -30px;
}
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button .coral-plugin-replies-icon {
visibility: visible;
}
}
+46 -11
View File
@@ -11,6 +11,16 @@ const ME_QUERY = gql`
query Me {
me {
status
comments {
id
body
asset {
id
title
url
}
created_at
}
}
}
`;
@@ -22,8 +32,23 @@ function fetchMe() {
}
// Dialog Actions
export const showSignInDialog = (offset = 0) => ({type: actions.SHOW_SIGNIN_DIALOG, offset});
export const hideSignInDialog = () => ({type: actions.HIDE_SIGNIN_DIALOG});
export const showSignInDialog = () => dispatch => {
const signInPopUp = window.open(
'/embed/stream/login',
'Login',
'menubar=0,resizable=0,width=500,height=550,top=200,left=500'
);
signInPopUp.onbeforeunload = () => {
dispatch(checkLogin());
fetchMe();
};
dispatch({type: actions.SHOW_SIGNIN_DIALOG});
};
export const hideSignInDialog = () => dispatch => {
dispatch({type: actions.HIDE_SIGNIN_DIALOG});
window.close();
};
export const createUsernameRequest = () => ({type: actions.CREATE_USERNAME_REQUEST});
export const showCreateUsernameDialog = () => ({type: actions.SHOW_CREATEUSERNAME_DIALOG});
@@ -47,29 +72,39 @@ export const createUsername = (userId, formData) => dispatch => {
});
};
export const changeView = view => dispatch =>
export const changeView = view => dispatch => {
dispatch({
type: actions.CHANGE_VIEW,
view
});
switch(view) {
case 'SIGNUP':
window.resizeTo(500, 800);
break;
case 'FORGOT':
window.resizeTo(500, 400);
break;
default:
window.resizeTo(500, 550);
}
};
export const cleanState = () => ({type: actions.CLEAN_STATE});
// Sign In Actions
const signInRequest = () => ({type: actions.FETCH_SIGNIN_REQUEST});
const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin});
// TODO: revisit login redux flow.
// const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin});
//
const signInFailure = error => ({type: actions.FETCH_SIGNIN_FAILURE, error});
export const fetchSignIn = (formData) => (dispatch) => {
dispatch(signInRequest());
return coralApi('/auth/local', {method: 'POST', body: formData})
.then(({user}) => {
const isAdmin = !!user && !!user.roles.filter(i => i === 'ADMIN').length;
dispatch(signInSuccess(user, isAdmin));
dispatch(hideSignInDialog());
fetchMe();
})
.then(() => dispatch(hideSignInDialog()))
.catch(error => {
if (error.metadata) {
@@ -121,7 +156,7 @@ export const facebookCallback = (err, data) => dispatch => {
dispatch(signInFacebookSuccess(user));
dispatch(hideSignInDialog());
dispatch(showCreateUsernameDialog());
fetchMe();
dispatch(hideSignInDialog());
} catch (err) {
dispatch(signInFacebookFailure(err));
return;
-4
View File
@@ -1,16 +1,12 @@
import store from './services/store';
import pym from './services/PymConnection';
import I18n from './modules/i18n/i18n';
import actions from './actions';
import Slot from './components/Slot';
// TODO (bc): Deprecate old actions. Spreading actions is now needed.
export default {
pym,
Slot,
I18n,
store,
actions,
...actions
};
+1 -2
View File
@@ -28,8 +28,7 @@ export default function auth (state = initialState, action) {
switch (action.type) {
case actions.SHOW_SIGNIN_DIALOG :
return state
.set('showSignInDialog', true)
.set('signInOffset', action.offset);
.set('showSignInDialog', true);
case actions.HIDE_SIGNIN_DIALOG :
return state.merge(Map({
isLoading: false,
+2
View File
@@ -41,3 +41,5 @@ export function injectReducers(reducers) {
storeReducers = {...storeReducers, ...reducers};
store.replaceReducer(combineReducers(storeReducers));
}
window.coralStore = store;
+30 -6
View File
@@ -1,7 +1,31 @@
/**
* getActionSummary
* retrieves the action summary based on the type and the comment
*/
export const getTotalActionCount = (type, comment) => {
return comment.action_summaries
.filter(s => s.__typename === type)
.reduce((total, summary) => {
return total + summary.count;
}, 0);
};
export const getActionSummary = (type, comment) =>
comment.action_summaries.filter(a => a.__typename === type)[0];
export const iPerformedThisAction = (type, comment) => {
// if there is a current_user on any of the ActionSummary(s), the user performed this action
return comment.action_summaries
.filter(a => a.__typename === type)
.some(a => a.current_user);
};
export const getMyActionSummary = (type, comment) => {
return comment.action_summaries
.filter(a => a.__typename === type)
.find(a => a.current_user);
};
/**
* getActionSummary
* retrieves the action summaries based on the type and the comment
* array could be length > 1, as in the case of FlagActionSummary
*/
export const getActionSummary = (type, comment) => {
return comment.action_summaries.filter(a => a.__typename === type);
};
+1 -1
View File
@@ -2,7 +2,7 @@ import React, {Component, PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
import {Button} from 'coral-ui';
import {Slot} from 'coral-framework';
import Slot from 'coral-framework/components/Slot';
import {connect} from 'react-redux';
const name = 'coral-plugin-commentbox';
+6 -7
View File
@@ -21,15 +21,14 @@ class FlagButton extends Component {
// When the "report" button is clicked expand the menu
onReportClick = () => {
const {currentUser, flag, deleteAction} = this.props;
const {currentUser, deleteAction, flaggedByCurrentUser, flag} = this.props;
const {localPost, localDelete} = this.state;
const flagged = (flag && flag.current_user && !localDelete) || localPost;
const localFlagged = (flaggedByCurrentUser && !localDelete) || localPost;
if (!currentUser) {
const offset = document.getElementById(`c_${this.props.id}`).getBoundingClientRect().top - 75;
this.props.showSignInDialog(offset);
this.props.showSignInDialog();
return;
}
if (flagged) {
if (localFlagged) {
this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true});
deleteAction(localPost || flag.current_user.id);
} else if (this.state.showMenu){
@@ -130,9 +129,9 @@ class FlagButton extends Component {
}
render () {
const {flag, getPopupMenu} = this.props;
const {getPopupMenu, flaggedByCurrentUser} = this.props;
const {localPost, localDelete} = this.state;
const flagged = (flag && flag.current_user && !localDelete) || localPost;
const flagged = (flaggedByCurrentUser && !localDelete) || localPost;
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
return <div className={`${name}-container`}>
+2 -3
View File
@@ -27,16 +27,15 @@ class LikeButton extends Component {
render() {
const {like, id, postLike, deleteAction, showSignInDialog, currentUser} = this.props;
let {totalLikes: count} = this.props;
const {localPost, localDelete} = this.state;
const liked = (like && like.current_user && !localDelete) || localPost;
let count = like ? like.count : 0;
if (localPost) {count += 1;}
if (localDelete) {count -= 1;}
const onLikeClick = () => {
if (!currentUser) {
const offset = document.getElementById(`c_${id}`).getBoundingClientRect().top - 75;
showSignInDialog(offset);
showSignInDialog();
return;
}
if (currentUser.banned) {
@@ -1,17 +1,13 @@
import React from 'react';
import styles from './NotLoggedIn.css';
import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
import translations from '../translations';
import I18n from 'coral-framework/modules/i18n/i18n';
const lang = new I18n(translations);
export default ({showSignInDialog, requireEmailConfirmation}) => (
export default ({showSignInDialog}) => (
<div className={styles.message}>
<SignInContainer noButton={true} requireEmailConfirmation={requireEmailConfirmation}/>
<div>
<a onClick={() => {
showSignInDialog();
}}>{lang.t('signIn')}</a> {lang.t('toAccess')}
<a onClick={showSignInDialog}>{lang.t('signIn')}</a> {lang.t('toAccess')}
</div>
<div>
{lang.t('fromSettingsPage')}
@@ -12,7 +12,7 @@ import NotLoggedIn from '../components/NotLoggedIn';
import IgnoredUsers from '../components/IgnoredUsers';
import {Spinner} from 'coral-ui';
import CommentHistory from 'coral-plugin-history/CommentHistory';
import {showSignInDialog} from 'coral-framework/actions/auth';
import {showSignInDialog, checkLogin} from 'coral-framework/actions/auth';
import translations from '../translations';
const lang = new I18n(translations);
@@ -34,17 +34,17 @@ class ProfileContainer extends Component {
}
render() {
const {asset, showSignInDialog, data, myIgnoredUsersData, stopIgnoringUser} = this.props;
const {asset, data, showSignInDialog, myIgnoredUsersData, stopIgnoringUser} = this.props;
const {me} = this.props.data;
if (!me) {
return <NotLoggedIn showSignInDialog={showSignInDialog} requireEmailConfirmation={asset.settings.requireEmailConfirmation}/>;
}
if (data.loading) {
return <Spinner/>;
}
if (!me) {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
}
const localProfile = this.props.user.profiles.find(p => p.provider === 'local');
const emailAddress = localProfile && localProfile.id;
@@ -83,7 +83,6 @@ class ProfileContainer extends Component {
:
<p>{lang.t('userNoComment')}</p>
}
</div>
);
}
@@ -96,7 +95,7 @@ const mapStateToProps = state => ({
});
const mapDispatchToProps = dispatch =>
bindActionCreators({showSignInDialog}, dispatch);
bindActionCreators({showSignInDialog, checkLogin}, dispatch);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
@@ -10,16 +10,12 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
const lang = new I18n(translations);
const CreateUsernameDialog = ({open, handleClose, offset, formData, handleSubmitUsername, handleChange, ...props}) => {
const CreateUsernameDialog = ({open, handleClose, formData, handleSubmitUsername, handleChange, ...props}) => {
return (
<Dialog
className={styles.dialogusername}
id="createUsernameDialog"
open={open}
style={{
position: 'relative',
top: offset !== 0 && offset
}}>
open={open}>
<span className={styles.close} onClick={handleClose}>×</span>
<div>
<div className={styles.header}>
@@ -42,6 +38,7 @@ const CreateUsernameDialog = ({open, handleClose, offset, formData, handleSubmit
<div className={styles.saveusername}>
<TextField
id="username"
style={{fontSize: 16}}
type="string"
label={lang.t('createdisplay.username')}
value={formData.username}
@@ -31,6 +31,7 @@ class ForgotContent extends React.Component {
<input
ref={input => this.emailInput = input}
type="text"
style={{fontSize: 16}}
id="email"
name="email" />
</div>
@@ -6,15 +6,11 @@ import SignInContent from './SignInContent';
import SignUpContent from './SignUpContent';
import ForgotContent from './ForgotContent';
const SignDialog = ({open, view, handleClose, offset, ...props}) => (
const SignDialog = ({open, view, handleClose, ...props}) => (
<Dialog
className={styles.dialog}
id="signInDialog"
open={open}
style={{
position: 'fixed',
top: offset !== 0 && offset
}}>
open={open}>
<span className={styles.close} onClick={handleClose}>×</span>
{view === 'SIGNIN' && <SignInContent {...props} />}
{view === 'SIGNUP' && <SignUpContent {...props} />}
@@ -20,8 +20,8 @@ const SignInContent = ({
}) => {
return (
<div>
<div className={styles.header}>
<div className="coral-sign-in">
<div className={`${styles.header} header`}>
<h1>
{auth.emailVerificationFailure ? lang.t('signIn.emailVerifyCTA') : lang.t('signIn.signIn')}
</h1>
@@ -42,7 +42,7 @@ const SignInContent = ({
{emailVerificationSuccess && <Success />}
</form>
: <div>
<div className={styles.socialConnections}>
<div className={`${styles.socialConnections} social-connections`}>
<Button cStyle="facebook" onClick={fetchSignInFacebook} full>
{lang.t('signIn.facebookSignIn')}
</Button>
@@ -58,6 +58,7 @@ const SignInContent = ({
type="email"
label={lang.t('signIn.email')}
value={formData.email}
style={{fontSize: 16}}
onChange={handleChange}
/>
<TextField
@@ -65,6 +66,7 @@ const SignInContent = ({
type="password"
label={lang.t('signIn.password')}
value={formData.password}
style={{fontSize: 16}}
onChange={handleChange}
/>
<div className={styles.action}>
@@ -80,7 +82,7 @@ const SignInContent = ({
</form>
</div>
}
<div className={styles.footer}>
<div className={`${styles.footer} footer`}>
<span><a onClick={() => changeView('FORGOT')}>{lang.t('signIn.forgotYourPass')}</a></span>
<span>
{lang.t('signIn.needAnAccount')}
@@ -83,6 +83,7 @@ class SignUpContent extends React.Component {
type="email"
label={lang.t('signIn.email')}
value={formData.email}
style={{fontSize: 16}}
showErrors={showErrors}
errorMsg={errors.email}
onChange={handleChange}
@@ -93,6 +94,7 @@ class SignUpContent extends React.Component {
label={lang.t('signIn.username')}
value={formData.username}
showErrors={showErrors}
style={{fontSize: 16}}
errorMsg={errors.username}
onChange={handleChange}
/>
@@ -102,6 +104,7 @@ class SignUpContent extends React.Component {
label={lang.t('signIn.password')}
value={formData.password}
showErrors={showErrors}
style={{fontSize: 16}}
errorMsg={errors.password}
onChange={handleChange}
minLength="8"
@@ -112,6 +115,7 @@ class SignUpContent extends React.Component {
type="password"
label={lang.t('signIn.confirmPassword')}
value={formData.confirmPassword}
style={{fontSize: 16}}
showErrors={showErrors}
errorMsg={errors.confirmPassword}
onChange={handleChange}
@@ -100,12 +100,11 @@ class ChangeUsernameContainer extends Component {
}
render() {
const {loggedIn, auth, offset} = this.props;
const {loggedIn, auth} = this.props;
return (
<div>
<CreateUsernameDialog
open={auth.showCreateUsernameDialog && auth.user.canEditName}
offset={offset}
handleClose={this.handleClose}
loggedIn={loggedIn}
handleSubmitUsername={this.handleSubmitUsername}
@@ -1,7 +1,6 @@
import React, {Component, PropTypes} from 'react';
import {connect} from 'react-redux';
import SignDialog from '../components/SignDialog';
import Button from 'coral-ui/components/Button';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
import I18n from 'coral-framework/modules/i18n/i18n';
@@ -13,7 +12,6 @@ import {
changeView,
fetchSignUp,
fetchSignIn,
showSignInDialog,
hideSignInDialog,
fetchSignInFacebook,
fetchSignUpFacebook,
@@ -147,23 +145,18 @@ class SignInContainer extends Component {
handleSignIn(e) {
e.preventDefault();
this.props.fetchSignIn(this.state.formData)
.then(this.props.refetch);
this.props.fetchSignIn(this.state.formData);
}
render() {
const {auth, showSignInDialog, noButton, offset, requireEmailConfirmation} = this.props;
const {auth, requireEmailConfirmation} = this.props;
const {emailVerificationLoading, emailVerificationSuccess} = auth;
return (
<div>
{!noButton && <Button id='coralSignInButton' onClick={showSignInDialog} full>
Sign in to comment
</Button>}
<SignDialog
open={auth.showSignInDialog}
open={true}
view={auth.view}
offset={offset}
emailVerificationEnabled={requireEmailConfirmation}
emailVerificationLoading={emailVerificationLoading}
emailVerificationSuccess={emailVerificationSuccess}
@@ -189,7 +182,6 @@ const mapDispatchToProps = dispatch => ({
fetchSignUpFacebook: () => dispatch(fetchSignUpFacebook()),
fetchForgotPassword: formData => dispatch(fetchForgotPassword(formData)),
requestConfirmEmail: (email, url) => dispatch(requestConfirmEmail(email, url)),
showSignInDialog: () => dispatch(showSignInDialog()),
changeView: view => dispatch(changeView(view)),
handleClose: () => dispatch(hideSignInDialog()),
invalidForm: error => dispatch(invalidForm(error)),
+71 -37
View File
@@ -53,6 +53,71 @@ const forEachField = (schema, fn) => {
});
};
/**
* Decorates the field with the post resolvers (if available) and attaches a
* default type in the form of `Default${typeName}`.
*/
const decorateResolveFunction = (field, typeName, fieldName, post) => {
// Cache the original resolverType function.
let resolveType = field.resolveType;
// defaultResolveType is the default type that is resolved on a resolver
// when the interface being looked up is not defined.
const defaultResolveType = `Default${typeName}`;
// Return the function to handle the resolveType hooks.
const defaultResolveFn = (obj, context, info) => {
let type = resolveType(obj, context, info);
// Only if a previous resolver was unable to resolve the field type do we
// progress to the hooks (in order!) to resolve the field name until we
// have resolved it.
if (typeof type !== 'undefined' && type != null) {
return type;
}
// All else fails, resort to the defaultResolveType.
return defaultResolveType;
};
// This only needs to do something if post hooks are defined.
if (post.length === 0) {
// Set the default on the resolveType function.
field.resolveType = defaultResolveFn;
return;
}
// Ensure it matches the format we expect.
Joi.assert(post, Joi.array().items(Joi.func().maxArity(3)), `invalid post hooks were found for ${typeName}.${fieldName}`);
// Return the function to handle the resolveType hooks.
field.resolveType = (obj, context, info) => {
let type = defaultResolveFn(obj, context, info);
// Only if a previous resolver was unable to resolve the field type do we
// progress to the hooks (in order!) to resolve the field name until we
// have resolved it.
if (typeof type !== 'undefined' && type != null && type !== defaultResolveType) {
return type;
}
// We will walk through the post hooks until we find the right one. This
// follows what redux does to combine existing reducers.
for (let i = 0; i < post.length; i++) {
let resolveType = post[i];
let resolvedType = resolveType(obj, context, info);
if (typeof resolvedType !== 'undefined' && resolvedType != null) {
return resolvedType;
}
}
return type;
};
};
/**
* Decorates the schema with pre and post hooks as provided by the Plugin
* Manager.
@@ -115,11 +180,6 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa
post: []
});
// If we have no hooks to add here, don't try to modify anything.
if (pre.length === 0 && post.length === 0) {
return;
}
// If this is a resolve type, we need to do some specific things to handle
// this type of field.
if (isResolveType) {
@@ -129,39 +189,13 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa
throw new Error(`invalid pre hooks were found for ${typeName}.${fieldName}, only post hooks are supported on the __resolveType hook`);
}
// This only needs to do something if post hooks are defined.
if (post.length === 0) {
return;
}
// Ensure it matches the format we expect.
Joi.assert(post, Joi.array().items(Joi.func().maxArity(3)), `invalid post hooks were found for ${typeName}.${fieldName}`);
// Cache the original resolverType function.
let resolveType = field.resolveType;
// Return the function to handle the resolveType hooks.
field.resolveType = (obj, context, info) => {
let type = resolveType(obj, context, info);
// Only if a previous resolver was unable to resolve the field type do we
// progress to the hooks (in order!) to resolve the field name until we
// have resolved it.
if (typeof type !== 'undefined' && type != null) {
return type;
}
// We will walk through the post hooks until we find the right one. This
// follows what redux does to combine existing reducers.
for (let i = 0; i < post.length; i++) {
let resolveType = post[i];
type = resolveType(obj, context, info);
if (typeof type !== 'undefined' && type != null) {
return type;
}
}
};
// Decorate the resolve function on the field with the new resolveType func.
decorateResolveFunction(field, typeName, fieldName, post);
return;
}
// If we have no hooks to add here, don't try to modify anything.
if (pre.length === 0 && post.length === 0) {
return;
}
+1 -1
View File
@@ -8,7 +8,7 @@ const ActionSummary = {
case 'DONTAGREE':
return 'DontAgreeActionSummary';
}
},
}
};
module.exports = ActionSummary;
+3 -1
View File
@@ -8,7 +8,9 @@ const FlagAction = {
return group_id;
},
user({user_id}, _, {loaders: {Users}}) {
return Users.getByID.load(user_id);
if (user_id) {
return Users.getByID.load(user_id);
}
},
};
+3 -2
View File
@@ -84,9 +84,10 @@ const RootQuery = {
},
myIgnoredUsers: async (_, args, {user, loaders: {Users}}) => {
if (user == null) {
return [];
if (!user) {
return null;
}
// get currentUser again since context.user was out of date when running test/graph/mutations/ignoreUser
const currentUser = (await Users.getByQuery({ids: [user.id], limit: 1}))[0];
if ( ! (currentUser && Array.isArray(currentUser.ignoresUsers) && currentUser.ignoresUsers.length)) {
+39 -3
View File
@@ -31,7 +31,7 @@ type User {
username: String!
# Action summaries against the user.
action_summaries: [ActionSummary]
action_summaries: [ActionSummary]!
# Actions completed on the parent.
actions: [Action]
@@ -197,7 +197,7 @@ type Comment {
actions: [Action]
# Action summaries against a comment.
action_summaries: [ActionSummary]
action_summaries: [ActionSummary]!
# The asset that a comment was made on.
asset: Asset
@@ -229,6 +229,22 @@ interface Action {
created_at: Date
}
# DefaultAction is the Action provided for undefined types.
type DefaultAction implements Action {
# The ID of the action.
id: ID!
# The author of the action.
user: User
# The time when the Action was updated.
updated_at: Date
# The time when the Action was created.
created_at: Date
}
# A summary of actions based on the specific grouping of the group_id.
interface ActionSummary {
@@ -239,6 +255,16 @@ interface ActionSummary {
current_user: Action
}
# DefaultActionSummary is the ActionSummary provided for undefined types.
type DefaultActionSummary implements ActionSummary {
# The count of actions with this group.
count: Int
# The current user's action.
current_user: Action
}
# A summary of actions for a specific action type on an Asset.
interface AssetActionSummary {
@@ -249,6 +275,16 @@ interface AssetActionSummary {
actionableItemCount: Int
}
# DefaultAssetActionSummary is the AssetActionSummary provided for undefined types.
type DefaultAssetActionSummary implements AssetActionSummary {
# Number of actions associated with actionable types on this this Asset.
actionCount: Int
# Number of unique actionable types that are referenced by the actions.
actionableItemCount: Int
}
# A summary of counts related to all the Flags on an Asset.
type FlagAssetActionSummary implements AssetActionSummary {
@@ -440,7 +476,7 @@ type Asset {
# Summary of all Actions against all entities associated with the Asset.
# (likes, flags, etc.). Requires the `ADMIN` role.
action_summaries: [AssetActionSummary]
action_summaries: [AssetActionSummary!]
# The date that the asset was created.
created_at: Date
+1 -1
View File
@@ -182,6 +182,6 @@
"webpack": "^2.3.1"
},
"engines": {
"node": "^7.9.0"
"node": "^7.8.0"
}
}
@@ -5,6 +5,7 @@ import Icon from './Icon';
import {I18n} from 'coral-framework';
import cn from 'classnames';
import translations from '../translations.json';
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
const lang = new I18n(translations);
@@ -14,13 +15,11 @@ class RespectButton extends Component {
const {postRespect, showSignInDialog, deleteAction, commentId} = this.props;
const {me, comment} = this.props.data;
const respect = comment.action_summaries[0];
const respected = (respect && respect.current_user);
const myRespectActionSummary = getMyActionSummary('RespectActionSummary', comment);
// If the current user does not exist, trigger sign in dialog.
if (!me) {
const offset = document.getElementById(`c_${commentId}`).getBoundingClientRect().top - 75;
showSignInDialog(offset);
showSignInDialog();
return;
}
@@ -29,29 +28,33 @@ class RespectButton extends Component {
return;
}
if (!respected) {
if (myRespectActionSummary) {
deleteAction(myRespectActionSummary.current_user.id);
} else {
postRespect({
item_id: commentId,
item_type: 'COMMENTS'
});
} else {
deleteAction(respect.current_user.id);
}
}
render() {
const {comment} = this.props.data;
const respect = comment && comment.action_summaries && comment.action_summaries[0];
const respected = respect && respect.current_user;
let count = respect ? respect.count : 0;
if (!comment) {
return null;
}
const myRespect = getMyActionSummary('RespectActionSummary', comment);
let count = getTotalActionCount('RespectActionSummary', comment);
return (
<div className={styles.respect}>
<button
className={cn(styles.button, {[styles.respected]: respected})}
className={cn(styles.button, {[styles.respected]: myRespect})}
onClick={this.handleClick} >
<span>{lang.t(respected ? 'respected' : 'respect')}</span>
<Icon className={cn(styles.icon, {[styles.respected]: respected})} />
<span>{lang.t(myRespect ? 'respected' : 'respect')}</span>
<Icon className={cn(styles.icon, {[styles.respected]: myRespect})} />
{count > 0 && count}
</button>
</div>
@@ -64,4 +67,3 @@ RespectButton.propTypes = {
};
export default RespectButton;
@@ -10,6 +10,8 @@ import RespectButton from '../components/RespectButton';
// See https://dev-blog.apollodata.com/apollo-clients-new-imperative-store-api-6cb69318a1e3
// and https://github.com/apollographql/apollo-client/issues/1224
const isRespectAction = (a) => a.__typename === 'RespectActionSummary';
export const RESPECT_QUERY = gql`
query RespectQuery($commentId: ID!) {
comment(id: $commentId) {
@@ -52,18 +54,21 @@ const withDeleteAction = graphql(gql`
},
updateQueries: {
RespectQuery: (prev) => {
if (get(prev, 'comment.action_summaries.0.current_user.id') !== id) {
const action_summaries = prev.comment.action_summaries;
const idx = action_summaries.findIndex(isRespectAction);
if (idx < 0 || get(action_summaries[idx], 'current_user.id') !== id) {
return prev;
}
const next = {
...prev,
comment: {
...prev.comment,
action_summaries: [{
__typename: 'RespectActionSummary',
count: prev.comment.action_summaries[0].count - 1,
current_user: null,
}],
action_summaries: action_summaries.map(
(a, i) => i !== idx ? a : ({
...a,
count: a.count - 1,
current_user: null,
})),
}
};
return next;
@@ -102,21 +107,40 @@ const withPostRespect = graphql(gql`
},
updateQueries: {
RespectQuery: (prev, {mutationResult, queryVariables}) => {
if (queryVariables.commentId !== respect.item_id ||
get(prev, 'comment.action_summaries.0.current_user')) {
if (queryVariables.commentId !== respect.item_id) {
return prev;
}
let action_summaries = prev.comment.action_summaries;
let idx = action_summaries.findIndex(isRespectAction);
// Check whether we already respected this comment.
if (idx >= 0 && action_summaries[idx].current_user) {
return prev;
}
if (idx < 0) {
// Add initial action when it doesn't exist.
action_summaries = action_summaries.concat([{
__typename: 'RespectActionSummary',
count: 0,
current_user: null,
}]);
idx = action_summaries.length - 1;
}
const respectAction = mutationResult.data.createRespect.respect;
const count = prev.comment.action_summaries[0] ? prev.comment.action_summaries[0].count : 0;
const next = {
...prev,
comment: {
...prev.comment,
action_summaries: [{
__typename: 'RespectActionSummary',
count: count + 1,
current_user: respectAction,
}],
action_summaries: action_summaries.map(
(a, i) => i !== idx ? a : ({
...a,
count: a.count + 1,
current_user: respectAction,
})),
}
};
return next;
@@ -138,4 +162,3 @@ const enhance = compose(
);
export default enhance(RespectButton);
+8 -2
View File
@@ -48,10 +48,16 @@ module.exports = class ActionsService {
* Finds actions in an array of ids.
* @param {String} ids array of user identifiers (uuid)
*/
static findByItemIdArray(item_ids) {
return ActionModel.find({
static async findByItemIdArray(item_ids) {
let actions = await ActionModel.find({
'item_id': {$in: item_ids}
});
if (actions === null) {
return [];
}
return actions;
}
/**
+2
View File
@@ -1,6 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta property="csrf" content="<%= csrfToken %>">
<link rel="stylesheet" type="text/css" href="/client/embed/stream/default.css">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">