merge conficts

This commit is contained in:
okbel
2017-12-20 16:06:55 -03:00
68 changed files with 2102 additions and 926 deletions
+1
View File
@@ -13,6 +13,7 @@ client/coral-framework/graphql/introspection.json
.idea/
*.swp
*.DS_STORE
.prettierrc.json
coverage/
test/e2e/reports/
+21
View File
@@ -1,8 +1,10 @@
const express = require('express');
const morgan = require('morgan');
const path = require('path');
const uuid = require('uuid');
const merge = require('lodash/merge');
const helmet = require('helmet');
const plugins = require('./services/plugins');
const compression = require('compression');
const {HELMET_CONFIGURATION} = require('./config');
const {MOUNT_PATH} = require('./url');
@@ -12,6 +14,25 @@ const {ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT} = require('./config');
const app = express();
// Request Identity Middleware
app.use((req, res, next) => {
req.id = uuid.v4();
next();
});
//==============================================================================
// PLUGIN PRE APPLICATION MIDDLEWARE
//==============================================================================
// Inject server route plugins.
plugins.get('server', 'app').forEach(({plugin, app: callback}) => {
debug(`added plugin '${plugin.name}'`);
// Pass the app to the plugin to mount it's routes.
callback(app);
});
//==============================================================================
// APPLICATION WIDE MIDDLEWARE
//==============================================================================
@@ -34,3 +34,8 @@ export const storySearchChange = (value) => ({
export const clearState = () => ({
type: actions.MODERATION_CLEAR_STATE
});
export const selectCommentId = (id) => ({
type: actions.MODERATION_SELECT_COMMENT,
id,
});
@@ -21,10 +21,11 @@ class CommentDetails extends Component {
this.setState((state) => ({
showDetail: !state.showDetail
}));
this.props.clearHeightCache && this.props.clearHeightCache();
}
render() {
const {data, root, comment} = this.props;
const {data, root, comment, clearHeightCache} = this.props;
const {showDetail} = this.state;
const queryData = {
root,
@@ -44,12 +45,14 @@ class CommentDetails extends Component {
<Slot
fill="adminCommentDetailArea"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
more={showDetail}
/>
{showDetail && <Slot
fill="adminCommentMoreDetails"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>}
</div>
@@ -61,6 +64,7 @@ CommentDetails.propTypes = {
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
comment: PropTypes.object.isRequired,
clearHeightCache: PropTypes.func,
};
export default CommentDetails;
@@ -6,3 +6,4 @@ export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH';
export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH';
export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE';
export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE';
export const MODERATION_SELECT_COMMENT = 'MODERATION_SELECT_COMMENT';
@@ -144,7 +144,7 @@ UserDetailContainer.propTypes = {
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
...CoralAdmin_Moderation_CommentConnection
...CoralAdmin_UserDetail_CommentConnection
}
}
${commentConnectionFragment}
@@ -7,6 +7,7 @@ const initialState = {
storySearchString: '',
shortcutsNoteVisible: 'show',
sortOrder: 'DESC',
selectedCommentId: '',
};
export default function moderation (state = initialState, action) {
@@ -51,6 +52,11 @@ export default function moderation (state = initialState, action) {
...state,
sortOrder: action.order,
};
case actions.MODERATION_SELECT_COMMENT:
return {
...state,
selectedCommentId: action.id,
};
default:
return state;
}
@@ -0,0 +1,25 @@
import React from 'react';
import {Spinner} from 'coral-ui';
import PropTypes from 'prop-types';
/**
* AutoLoadMore with call `loadMore` the moment it is rendered and shows a Spinner.
*/
class AutoLoadMore extends React.Component {
componentDidMount() {
if(!this.props.loading) {
this.props.loadMore();
}
}
render() {
return <Spinner />;
}
}
AutoLoadMore.propTypes = {
loading: PropTypes.bool.isRequired,
loadMore: PropTypes.func.isRequired,
};
export default AutoLoadMore;
@@ -10,7 +10,9 @@
position: relative;
transition: all 200ms;
padding: 10px 0;
margin-top: 13px;
min-height: 0;
outline: 0;
/*
Fix rendering issues in Safari by promoting this
@@ -25,6 +27,10 @@
}
}
.dangling {
background-color: #efefef;
}
.container {
padding: 0 14px;
}
@@ -17,21 +17,36 @@ import RejectButton from 'coral-admin/src/components/RejectButton';
import t, {timeago} from 'coral-framework/services/i18n';
class Comment extends React.Component {
ref = null;
handleRef = (ref) => (this.ref = ref);
handleFocusOrClick = () => {
if (!this.props.selected) {
this.props.selectComment();
}
};
viewUserDetail = () => {
const {viewUserDetail, comment} = this.props;
return viewUserDetail(comment.user.id);
};
approve = () => (this.props.comment.status === 'ACCEPTED'
? null
: this.props.acceptComment({commentId: this.props.comment.id})
);
approve = () =>
this.props.comment.status === 'ACCEPTED'
? null
: this.props.acceptComment({commentId: this.props.comment.id});
reject = () => (this.props.comment.status === 'REJECTED'
? null
: this.props.rejectComment({commentId: this.props.comment.id})
);
reject = () =>
this.props.comment.status === 'REJECTED'
? null
: this.props.rejectComment({commentId: this.props.comment.id});
componentDidUpdate(prev) {
if (!prev.selected && this.props.selected) {
this.ref.focus();
}
}
render() {
const {
@@ -42,6 +57,8 @@ class Comment extends React.Component {
root,
root: {settings},
currentAsset,
clearHeightCache,
dangling,
} = this.props;
const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
@@ -50,34 +67,48 @@ class Comment extends React.Component {
return (
<li
tabIndex={0}
className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected}, 'talk-admin-moderate-comment')}
className={cn(
className,
'mdl-card',
selectionStateCSS,
styles.root,
{[styles.selected]: selected, [styles.dangling]: dangling},
'talk-admin-moderate-comment'
)}
id={`comment_${comment.id}`}
onClick={this.handleFocusOrClick}
ref={this.handleRef}
onFocus={this.handleFocusOrClick}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<span
className={cn(styles.username, 'talk-admin-moderate-comment-username')}
onClick={this.viewUserDetail}>
className={cn(
styles.username,
'talk-admin-moderate-comment-username'
)}
onClick={this.viewUserDetail}
>
{comment.user.username}
</span>
<span className={styles.created}>
{timeago(comment.created_at)}
</span>
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
{comment.editing && comment.editing.edited ? (
<span>
&nbsp;<span className={styles.editedMarker}>
({t('comment.edited')})
</span>
</span>
) : null}
<div className={styles.adminCommentInfoBar}>
<CommentLabels
comment={comment}
/>
<CommentLabels comment={comment} />
<Slot
fill="adminCommentInfoBar"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
</div>
@@ -86,8 +117,11 @@ class Comment extends React.Component {
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
{!currentAsset && (
<Link to={`/admin/moderate/${comment.asset.id}`}>
{t('modqueue.moderate')}
</Link>
)}
</div>
<CommentAnimatedEdit body={comment.body}>
<div className={styles.itemBody}>
@@ -96,8 +130,7 @@ class Comment extends React.Component {
suspectWords={settings.wordlist.suspect}
bannedWords={settings.wordlist.banned}
body={comment.body}
/>
{' '}
/>{' '}
<a
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
@@ -109,6 +142,7 @@ class Comment extends React.Component {
<Slot
fill="adminCommentContent"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
<div className={styles.sideActions}>
@@ -130,6 +164,7 @@ class Comment extends React.Component {
<Slot
fill="adminSideActions"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
</div>
@@ -140,6 +175,7 @@ class Comment extends React.Component {
data={data}
root={root}
comment={comment}
clearHeightCache={clearHeightCache}
/>
</li>
);
@@ -149,10 +185,14 @@ class Comment extends React.Component {
Comment.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
selectComment: PropTypes.func,
rejectComment: PropTypes.func.isRequired,
onClick: PropTypes.func,
className: PropTypes.string,
dangling: PropTypes.bool,
currentAsset: PropTypes.object,
currentUserId: PropTypes.string.isRequired,
clearHeightCache: PropTypes.func,
comment: PropTypes.shape({
id: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
@@ -162,12 +202,12 @@ Comment.propTypes = {
created_at: PropTypes.string.isRequired,
user: PropTypes.shape({
id: PropTypes.string,
status: PropTypes.string
status: PropTypes.string,
}).isRequired,
asset: PropTypes.shape({
title: PropTypes.string,
url: PropTypes.string,
id: PropTypes.string
id: PropTypes.string,
}),
}),
data: PropTypes.object.isRequired,
@@ -12,15 +12,9 @@ import Slot from 'coral-framework/components/Slot';
import ViewOptions from './ViewOptions';
class Moderation extends Component {
constructor(props) {
super(props);
const comments = this.getComments(props);
this.state = {
selectedCommentId: comments[0] ? comments[0].id : null,
};
}
state = {
isLoadingMore: false,
};
componentWillMount() {
const {toggleModal, singleView} = this.props;
@@ -30,17 +24,16 @@ class Moderation extends Component {
key('esc', () => toggleModal(false));
key('ctrl+f', () => this.openSearch());
key('t', () => this.nextQueue());
key('j', () => this.select(true));
key('k', () => this.select(false));
key('f', () => this.moderate(false));
key('d', () => this.moderate(true));
this.getMenuItems()
.forEach((menuItem, idx) => key(`${idx + 1}`, () => this.selectQueue(menuItem)));
this.getMenuItems().forEach((menuItem, idx) =>
key(`${idx + 1}`, () => this.selectQueue(menuItem))
);
}
onClose = () => {
this.props.toggleModal(false);
}
};
nextQueue = () => {
const activeTab = this.props.activeTab;
@@ -48,39 +41,45 @@ class Moderation extends Component {
const menuItems = this.getMenuItems();
const activeTabIndex = menuItems.findIndex((item) => item === activeTab);
const nextQueueIndex = (activeTabIndex === menuItems.length - 1) ? 0 : activeTabIndex + 1;
const nextQueueIndex =
activeTabIndex === menuItems.length - 1 ? 0 : activeTabIndex + 1;
this.selectQueue(menuItems[nextQueueIndex]);
}
};
selectQueue = (key) => {
const assetId = this.props.data.variables.asset_id;
this.props.router.push(this.props.getModPath(key, assetId));
}
};
getMenuItems = () => Object.keys(this.props.queueConfig);
closeSearch = () => {
const {toggleStorySearch} = this.props;
toggleStorySearch(false);
}
};
openSearch = () => {
this.props.toggleStorySearch(true);
}
};
getActiveTabCount = (props = this.props) => {
return props.root[`${props.activeTab}Count`];
}
};
moderate = (accept) => {
const {acceptComment, rejectComment} = this.props;
const {selectedCommentId} = this.state;
const {
acceptComment,
rejectComment,
moderation: {selectedCommentId},
} = this.props;
// Accept or reject only if there's a selected comment
if(selectedCommentId != null){
if (selectedCommentId != null) {
const comments = this.getComments();
const commentIdx = comments.findIndex((comment) => comment.id === selectedCommentId);
const commentIdx = comments.findIndex(
(comment) => comment.id === selectedCommentId
);
const comment = comments[commentIdx];
if (accept) {
@@ -89,86 +88,27 @@ class Moderation extends Component {
comment.status !== 'REJECTED' && rejectComment({commentId: comment.id});
}
}
}
};
getComments = (props = this.props) => {
const {root, activeTab} = props;
return root[activeTab].nodes;
}
scrollTo = (toId, smooth = true) =>
document.querySelector(`#comment_${toId}`).scrollIntoView(smooth ? {behavior: 'smooth'} : {});
select = async (next, props = this.props, selectedCommentId = this.state.selectedCommentId) => {
const comments = this.getComments(props);
// No comments to be selected.
if (comments.length === 0){
return;
}
// Find current index if we have a selected comment.
const index = selectedCommentId
? comments.findIndex((comment) => comment.id === selectedCommentId)
: null;
if (next) {
// Grab first one if we don't have a selected comment yet.
if (!selectedCommentId) {
this.setState({selectedCommentId: comments[0].id}, () => this.scrollTo(comments[0].id));
return;
}
// Select next one when we still have more comments left.
if (index < comments.length - 1) {
this.setState({selectedCommentId: comments[index + 1].id}, () => this.scrollTo(comments[index + 1].id));
return;
} else {
// We hit the end of the list, load more comments if we have.
if (comments.length < this.getActiveTabCount()) {
const res = await this.loadMore();
// If `loadMore` was already in progress, res would be false.
if (res) {
// Select next comment after loading has completed.
this.select(true);
}
}
return;
}
} else {
// We have no selected comment, so just skip it.
if (!selectedCommentId) {
return;
}
// If we still have previous comments take the one before.
if (index > 0) {
this.setState({selectedCommentId: comments[index - 1].id}, () => this.scrollTo(comments[index - 1].id));
return;
}
}
}
};
loadMore = async () => {
if (!this.isLoadingMore) {
this.isLoadingMore = true;
if (!this.state.isLoadingMore) {
this.setState({isLoadingMore: true});
try {
const result = await this.props.loadMore(this.props.activeTab);
this.isLoadingMore = false;
this.setState({isLoadingMore: false});
return result;
}
catch (e) {
this.isLoadingMore = false;
} catch (e) {
this.setState({isLoadingMore: false});
throw e;
}
}
return false;
}
};
componentWillUnmount() {
key.unbind('s');
@@ -176,58 +116,23 @@ class Moderation extends Component {
key.unbind('esc');
key.unbind('ctrl+f');
key.unbind('t');
key.unbind('j');
key.unbind('k');
key.unbind('f');
key.unbind('d');
this.getMenuItems()
.forEach((menuItem, idx) => key.unbind(`${idx + 1}`));
this.getMenuItems().forEach((menuItem, idx) => key.unbind(`${idx + 1}`));
}
componentWillReceiveProps(nextProps) {
if (this.props.activeTab !== nextProps.activeTab) {
// Reset selection when changing tabs.
this.select(true, nextProps, null);
} else {
// Detect if comment has left the queue and find next or prev selected comment to set it
// as the new selectedCommentId.
const prevComments = this.getComments(this.props);
const nextComments = this.getComments(nextProps);
if (nextComments.length < prevComments.length) {
// Comments have changed, now check if our selected comment has left the queue.
if (
this.state.selectedCommentId &&
!nextComments.some((comment) => comment.id === this.state.selectedCommentId)
) {
// Determine a comment to select.
const prevIndex = prevComments.findIndex((comment) => comment.id === this.state.selectedCommentId);
if (prevIndex !== prevComments.length - 1) {
this.setState({selectedCommentId: prevComments[prevIndex + 1].id});
} else if(prevIndex > 0) {
this.setState({selectedCommentId: prevComments[prevIndex - 1].id});
} else {
this.setState({selectedCommentId: null});
}
}
}
}
}
componentDidUpdate(prevProps) {
// Scroll to comment when changing from single wiew to normal view.
if (prevProps.moderation.singleView !== this.props.moderation.singleView && this.state.selectedCommentId) {
this.scrollTo(this.state.selectedCommentId, false);
}
}
render () {
const {root, data, moderation, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
render() {
const {
root,
data,
moderation,
viewUserDetail,
activeTab,
getModPath,
queueConfig,
handleCommentChange,
...props
} = this.props;
const {asset} = root;
const assetId = asset && asset.id;
@@ -238,7 +143,7 @@ class Moderation extends Component {
key: queue,
name: queueConfig[queue].name,
icon: queueConfig[queue].icon,
count: root[`${queue}Count`]
count: root[`${queue}Count`],
}));
return (
@@ -255,7 +160,9 @@ class Moderation extends Component {
items={menuItems}
activeTab={activeTab}
/>
<div className={cn(styles.container, 'talk-admin-moderation-container')}>
<div
className={cn(styles.container, 'talk-admin-moderation-container')}
>
<ViewOptions
selectSort={this.props.setSortOrder}
sort={this.props.moderation.sortOrder}
@@ -266,15 +173,20 @@ class Moderation extends Component {
root={this.props.root}
currentAsset={asset}
comments={comments.nodes}
hasNextPage={comments.hasNextPage}
activeTab={activeTab}
singleView={moderation.singleView}
selectedCommentId={this.state.selectedCommentId}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
loadMore={this.loadMore}
commentBelongToQueue={this.props.commentBelongToQueue}
isLoadingMore={this.state.isLoadingMore}
commentCount={activeTabCount}
currentUserId={this.props.auth.user.id}
viewUserDetail={viewUserDetail}
selectCommentId={props.selectCommentId}
cleanUpQueue={props.cleanUpQueue}
/>
<ModerationKeysModal
hideShortcutsNote={props.hideShortcutsNote}
@@ -295,7 +207,7 @@ class Moderation extends Component {
queryData={{root, asset}}
activeTab={activeTab}
handleCommentChange={handleCommentChange}
fill='adminModeration'
fill="adminModeration"
/>
</div>
);
@@ -305,12 +217,15 @@ class Moderation extends Component {
Moderation.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
toggleModal: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
toggleStorySearch: PropTypes.func.isRequired,
getModPath: PropTypes.func.isRequired,
cleanUpQueue: PropTypes.func.isRequired,
storySearchChange: PropTypes.func.isRequired,
moderation: PropTypes.object.isRequired,
auth: PropTypes.object.isRequired,
queueConfig: PropTypes.object.isRequired,
commentBelongToQueue: PropTypes.func.isRequired,
handleCommentChange: PropTypes.func.isRequired,
setSortOrder: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
@@ -0,0 +1,3 @@
.root {
height: 100%;
}
@@ -2,29 +2,15 @@
padding: 8px 0;
list-style: none;
display: block;
min-height: 650px;
margin-top: 16px;
}
:global(html) {
height: inherit;
}
.list {
padding: 0;
margin: 0;
}
.commentLeave {
opacity: 1.0;
}
.commentLeaveActive {
opacity: 0;
transition: opacity 800ms;
}
.commentEnter {
opacity: 0;
}
.commentEnterActive {
opacity: 1.0;
transition: opacity 800ms;
outline: none;
}
@@ -4,26 +4,34 @@ import PropTypes from 'prop-types';
import Comment from '../containers/Comment';
import styles from './ModerationQueue.css';
import EmptyCard from '../../../components/EmptyCard';
import LoadMore from '../../../components/LoadMore';
import AutoLoadMore from './AutoLoadMore';
import ViewMore from './ViewMore';
import t from 'coral-framework/services/i18n';
import {CSSTransitionGroup} from 'react-transition-group';
import {
WindowScroller,
CellMeasurer,
CellMeasurerCache,
List,
} from 'react-virtualized';
import throttle from 'lodash/throttle';
import key from 'keymaster';
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
// resetCursors will return the id cursors of the first and second comment of
// the current comment list. The cursors are used to dertermine which
// comments to show. The spare cursor functions as a backup in case one
// of the comments gets deleted.
// the current comment The spare cursor functions as a backup in case one
// of the comments gets deleted. Additionally the new view based on the new
// cursors are also returned.
function resetCursors(state, props) {
let idCursors = [];
if (props.comments && props.comments.length) {
const idCursors = [props.comments[0].id];
idCursors.push(props.comments[0].id);
if (props.comments[1]) {
idCursors.push(props.comments[1].id);
}
return {idCursors};
}
return {idCursors: []};
const view = getVisibleComments(props.comments, idCursors[0]);
return {idCursors, view};
}
// invalidateCursor is called whenever a comment is removed which is referenced
@@ -40,91 +48,322 @@ function invalidateCursor(invalidated, state, props) {
idCursors.push(nextInLine.id);
}
}
return {idCursors};
return idCursors;
}
// getVisibileComments returns a list containing comments
// which comes after the `idCursor`.
function getVisibleComments(comments, idCursor) {
if (!comments) {
return [];
}
const view = [];
let pastCursor = false;
comments.forEach((comment) => {
if (comment.id === idCursor) {
pastCursor = true;
}
if (pastCursor) {
view.push(comment);
}
});
return view;
}
// Current keymapper to use for the CellMeasurer Cache.
let keyMapper = null;
// CellMeasurerCache is used to measure the size of the elements
// of the virtual list. We use a global one with a keyMapper that
// should resolve to a comment id, which is then used to cache the height.
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 250,
keyMapper: (index) => keyMapper(index),
});
class ModerationQueue extends React.Component {
isLoadingMore = false;
listRef = null;
callbackCaches = {
clearHeightCache: {},
selectCommentId: {},
};
constructor(props) {
super(props);
this.state = {
...resetCursors(this.state, props),
};
// Set keyMapper to map to comment ids.
keyMapper = (index) => {
const view = this.state.view;
if (index < view.length) {
return view[index].id;
} else if (index === view.length) {
return 'loadMore';
}
throw new Error(`unknown index ${index}`);
};
// Select first comment.
if (this.state.view.length) {
props.selectCommentId(this.state.view[0].id);
}
}
componentDidUpdate (prev) {
const {comments, commentCount} = this.props;
componentDidMount() {
key('j', () => this.selectDown());
key('k', () => this.selectUp());
// if the user just moderated the last (visible) comment
// AND there are more comments available on the server,
// go ahead and load more comments
if (prev.comments.length > 0 && comments.length === 0 && commentCount > 0) {
this.props.loadMore();
}
// TODO: Workaround for issue https://github.com/bvaughn/react-virtualized/issues/866
this.reflowList();
}
componentWillUnmount() {
key.unbind('j');
key.unbind('k');
// When switching queues, clean it up first.
// Removes dangling comments and reduce overly large
// lists and restore chronological order.
this.props.cleanUpQueue(this.props.activeTab);
}
componentWillReceiveProps(next) {
const {comments: prevComments} = this.props;
const {comments: nextComments} = next;
if (!prevComments && nextComments) {
this.setState(resetCursors);
// New comments where added and our cursor list is incomplete.
if (
this.state.idCursors.length < 2 &&
nextComments.length > this.state.idCursors.length
) {
this.setState(resetCursors(this.state, next));
return;
}
if (
prevComments && nextComments &&
nextComments.length < prevComments.length
) {
let idCursors = this.state.idCursors;
// Comments have been removed.
if (
prevComments &&
nextComments &&
nextComments.length < prevComments.length
) {
// Invalidate first cursor if referenced comment was removed.
if (this.state.idCursors[0] && !hasComment(nextComments, this.state.idCursors[0])) {
this.setState(invalidateCursor(0, this.state, next));
if (
this.state.idCursors[0] &&
!hasComment(nextComments, this.state.idCursors[0])
) {
idCursors = invalidateCursor(0, this.state, next);
}
// Invalidate second cursor if referenced comment was removed.
if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) {
this.setState(invalidateCursor(1, this.state, next));
if (
this.state.idCursors[1] &&
!hasComment(nextComments, this.state.idCursors[1])
) {
idCursors = invalidateCursor(1, this.state, next);
}
// Selected comment was removed, determine and set next selected comment.
if (
this.props.selectedCommentId &&
!hasComment(nextComments, this.props.selectedCommentId)
) {
const view = this.state.view;
let nextSelectedCommentId = null;
// Determine a comment to select.
const prevIndex = view.findIndex(
(comment) => comment.id === this.props.selectedCommentId
);
if (prevIndex !== view.length - 1) {
nextSelectedCommentId = view[prevIndex + 1].id;
} else if (prevIndex > 0) {
nextSelectedCommentId = view[prevIndex - 1].id;
}
this.props.selectCommentId(nextSelectedCommentId);
}
}
// Comments changed.
if (prevComments !== nextComments) {
const nextView = getVisibleComments(nextComments, idCursors[0]);
this.setState({idCursors, view: nextView});
// TODO: removing or adding a comment from the list seems to render incorrect, is this a bug?
// Find first changed comment and perform a reflow.
const index = this.state.view.findIndex(
(comment, i) => !nextView[i] || nextView[i].id !== comment.id
);
this.reflowList(index);
}
}
viewNewComments = () => {
this.setState(resetCursors);
componentDidUpdate(prev) {
const {commentCount, selectedCommentId} = this.props;
// If the user just moderated the last (visible) comment
// AND there are more comments available on the server,
// go ahead and load more comments
if (
prev.comments.length > 0 &&
this.getCommentCountWithoutDagling() === 0 &&
commentCount > 0
) {
this.props.loadMore();
}
// Scroll to selected comment.
if (prev.selectedCommentId !== selectedCommentId && this.listRef) {
const view = this.state.view;
const index = view.findIndex(({id}) => id === selectedCommentId);
this.listRef.scrollToRow(index);
}
}
// Returns comment counts without dangling comments.
getCommentCountWithoutDagling(props = this.props) {
return props.comments.filter((comment) =>
props.commentBelongToQueue(props.activeTab, comment)
).length;
}
async selectDown() {
const view = this.state.view;
const index = view.findIndex(({id}) => id === this.props.selectedCommentId);
if (
index === view.length - 1 &&
this.getCommentCountWithoutDagling() !== this.props.commentCount
) {
await this.props.loadMore();
this.selectDown();
return;
}
if (index < view.length - 1) {
this.props.selectCommentId(view[index + 1].id);
}
}
selectUp() {
const view = this.state.view;
const index = view.findIndex(({id}) => id === this.props.selectedCommentId);
if (index === 0 && view.length < this.props.comments.length) {
this.viewNewComments(() => this.selectUp());
return;
}
if (index > 0) {
this.props.selectCommentId(view[index - 1].id);
}
}
handleListRef = (list) => {
this.listRef = list;
};
// getVisibileComments returns a list containing comments
// which comes after the `idCursor`.
getVisibleComments() {
const {comments} = this.props;
const idCursor = this.state.idCursors[0];
viewNewComments = (callback) => {
this.setState(resetCursors, () => {
this.reflowList();
callback && callback();
});
};
if (!comments) {
return [];
reflowList = throttle((index) => {
if (index >= 0) {
cache.clear(index);
this.listRef && this.listRef.recomputeRowHeights(index);
} else {
cache.clearAll();
this.listRef && this.listRef.recomputeRowHeights();
}
}, 500);
rowRenderer = ({
index, // Index of row within collection
parent,
style, // Style object to be applied to row (to position it)
}) => {
const view = this.state.view;
const rowCount = view.length + 1;
let child = null;
let key = null;
// Last element of list is our AutoLoadMore component and contains an
// id indicating that this is the last element in list.
if (index === rowCount - 1) {
key = 'end-of-comment-list';
child = (
<div style={style} id={'end-of-comment-list'}>
{this.props.hasNextPage && (
<AutoLoadMore
loadMore={this.props.loadMore}
loading={this.props.isLoadingMore}
/>
)}
</div>
);
} else {
const comment = view[index];
// Use callback cache so not to change the identity of these arrow functions.
// Otherwise shallow compare will fail to optimize.
if (!this.callbackCaches.clearHeightCache[index]) {
this.callbackCaches.clearHeightCache[index] = () =>
this.reflowList(index);
}
if (!this.callbackCaches.selectCommentId[comment.id]) {
this.callbackCaches.selectCommentId[comment.id] = () =>
this.props.selectCommentId(comment.id);
}
key = comment.id;
child = (
<div style={style}>
<Comment
data={this.props.data}
root={this.props.root}
comment={comment}
dangling={
!this.props.commentBelongToQueue(this.props.activeTab, comment)
}
selected={comment.id === this.props.selectedCommentId}
viewUserDetail={this.props.viewUserDetail}
acceptComment={this.props.acceptComment}
rejectComment={this.props.rejectComment}
currentAsset={this.props.currentAsset}
currentUserId={this.props.currentUserId}
clearHeightCache={this.callbackCaches.clearHeightCache[index]}
selectComment={this.callbackCaches.selectCommentId[comment.id]}
/>
</div>
);
}
const view = [];
let pastCursor = false;
comments.forEach((comment) => {
if (comment.id === idCursor) {
pastCursor = true;
}
if (pastCursor) {
view.push(comment);
}
});
return view;
}
return (
<CellMeasurer
cache={cache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
>
{child}
</CellMeasurer>
);
};
render () {
render() {
const {
comments,
selectedCommentId,
commentCount,
singleView,
viewUserDetail,
activeTab,
...props
} = this.props;
@@ -137,7 +376,9 @@ class ModerationQueue extends React.Component {
}
if (singleView) {
const index = comments.findIndex((comment) => comment.id === selectedCommentId);
const index = comments.findIndex(
(comment) => comment.id === selectedCommentId
);
const comment = comments[index];
return (
<div className={styles.root}>
@@ -157,67 +398,55 @@ class ModerationQueue extends React.Component {
);
}
const view = this.getVisibleComments();
const view = this.state.view;
return (
<div className={styles.root}>
<ViewMore
viewMore={this.viewNewComments}
viewMore={() => this.viewNewComments()}
count={comments.length - view.length}
/>
<CSSTransitionGroup
key={activeTab}
component={'ul'}
className={styles.list}
transitionName={{
enter: styles.commentEnter,
enterActive: styles.commentEnterActive,
leave: styles.commentLeave,
leaveActive: styles.commentLeaveActive,
}}
transitionEnter={true}
transitionLeave={true}
transitionEnterTimeout={1000}
transitionLeaveTimeout={1000}
>
{
view
.map((comment) => {
return <Comment
data={this.props.data}
root={this.props.root}
key={comment.id}
comment={comment}
selected={comment.id === selectedCommentId}
viewUserDetail={viewUserDetail}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
currentAsset={props.currentAsset}
currentUserId={this.props.currentUserId}
/>;
})
}
</CSSTransitionGroup>
<LoadMore
loadMore={this.props.loadMore}
showLoadMore={comments.length < commentCount}
/>
<WindowScroller onResize={this.reflowList}>
{({height, isScrolling, onChildScroll, scrollTop}) => (
<List
ref={this.handleListRef}
autoHeight
className={styles.list}
style={{
width: '100%',
}}
height={height}
width={1280}
scrollTop={scrollTop}
isScrolling={isScrolling}
onScroll={onChildScroll}
rowCount={view.length + 1}
deferredMeasurementCache={cache}
rowRenderer={this.rowRenderer}
rowHeight={cache.rowHeight}
/>
)}
</WindowScroller>
</div>
);
}
}
ModerationQueue.propTypes = {
selectCommentId: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
viewUserDetail: PropTypes.func.isRequired,
currentAsset: PropTypes.object,
rejectComment: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
comments: PropTypes.array.isRequired,
commentBelongToQueue: PropTypes.func.isRequired,
cleanUpQueue: PropTypes.func.isRequired,
commentCount: PropTypes.number.isRequired,
loadMore: PropTypes.func.isRequired,
selectedCommentId: PropTypes.string,
singleView: PropTypes.bool,
isLoadingMore: PropTypes.bool,
hasNextPage: PropTypes.bool,
comments: PropTypes.array,
activeTab: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
@@ -13,6 +13,9 @@
background-color: #2376D8;
cursor: pointer;
text-transform: capitalize;
margin: 0;
margin-top: -18px;
margin-bottom: -4px;
}
.viewMore:hover {
@@ -8,7 +8,8 @@
overflow: visible;
height: 144px;
min-height: auto;
z-index: 4;
margin-top: 16px;
z-index: 10;
@media (--tablet) {
width: 650px;
@@ -11,7 +11,12 @@ import NotFoundAsset from '../components/NotFoundAsset';
import {isPremod, getModPath} from '../../../utils';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {handleCommentChange} from '../graphql';
import {
handleCommentChange,
commentBelongToQueue,
cleanUpQueue,
} from '../graphql';
import {viewUserDetail} from '../../../actions/userDetail';
import {
toggleModal,
@@ -20,7 +25,8 @@ import {
toggleStorySearch,
setSortOrder,
storySearchChange,
clearState
clearState,
selectCommentId,
} from 'actions/moderation';
import withQueueConfig from '../hoc/withQueueConfig';
import {notify} from 'coral-framework/actions/notification';
@@ -63,13 +69,15 @@ class ModerationContainer extends Component {
};
get activeTab() {
const {root: {asset, settings}} = this.props;
const id = getAssetId(this.props);
const tab = getTab(this.props);
// Grab premod from asset or from settings if it's defined.
const setting = id && asset && asset.settings ? asset.settings.moderation : settings.moderation;
const setting =
id && asset && asset.settings
? asset.settings.moderation
: settings.moderation;
const queue = isPremod(setting) ? 'premod' : 'new';
const activeTab = tab ? tab : queue;
@@ -82,60 +90,101 @@ class ModerationContainer extends Component {
{
document: COMMENT_ADDED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAdded: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentAdded: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_ACCEPTED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentAccepted: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_accepted',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_REJECTED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentRejected: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_rejected',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_RESET_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentReset: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const notifyText = this.props.auth.user.id === user.id
? ''
: t('modqueue.notify_reset', user.username, prepareNotificationText(comment.body));
updateQuery: (
prev,
{subscriptionData: {data: {commentReset: comment}}}
) => {
const user =
comment.status_history[comment.status_history.length - 1]
.assigned_by;
const notifyText =
this.props.auth.user.id === user.id
? ''
: t(
'modqueue.notify_reset',
user.username,
prepareNotificationText(comment.body)
);
return this.handleCommentChange(prev, comment, notifyText);
},
},
{
document: COMMENT_EDITED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentEdited: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_FLAGGED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
updateQuery: (
prev,
{subscriptionData: {data: {commentFlagged: comment}}}
) => {
return this.handleCommentChange(prev, comment);
},
},
];
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMoreThrottled(param));
this.subscriptions = parameters.map((param) =>
this.props.data.subscribeToMoreThrottled(param)
);
}
unsubscribe() {
@@ -158,24 +207,42 @@ class ModerationContainer extends Component {
}
componentWillReceiveProps(nextProps) {
// Resubscribe when we change between assets.
if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) {
if (
this.props.data.variables.asset_id !== nextProps.data.variables.asset_id
) {
this.resubscribe(nextProps.data.variables);
}
}
cleanUpQueue = (queue) => {
if (!this.props.data.loading) {
this.props.data.updateQuery((query) => {
return cleanUpQueue(
query,
queue,
this.props.moderation.sortOrder,
this.props.queueConfig
);
});
}
};
acceptComment = ({commentId}) => {
return this.props.setCommentStatus({commentId, status: 'ACCEPTED'});
}
};
rejectComment = ({commentId}) => {
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
}
};
commentBelongToQueue = (queue, comment) => {
return commentBelongToQueue(queue, comment, this.props.queueConfig);
};
loadMore = (tab) => {
const variables = {
limit: 10,
limit: 20,
cursor: this.props.root[tab].endCursor,
sortOrder: this.props.data.variables.sortOrder,
asset_id: this.props.data.variables.asset_id,
@@ -186,20 +253,19 @@ class ModerationContainer extends Component {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables,
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
updateQuery: (prev, {fetchMoreResult: {comments}}) => {
return update(prev, {
[tab]: {
nodes: {$push: comments.nodes},
hasNextPage: {$set: comments.hasNextPage},
startCursor: {$set: comments.startCursor},
endCursor: {$set: comments.endCursor},
},
});
}
},
});
};
render () {
render() {
const {root, root: {asset, settings}, data} = this.props;
const assetId = getAssetId(this.props);
@@ -209,20 +275,19 @@ class ModerationContainer extends Component {
if (assetId) {
if (asset === null) {
// Not found.
return <NotFoundAsset assetId={assetId} />;
}
}
if(data.loading) {
if (data.loading) {
// loading.
return <Spinner />;
}
const premodEnabled = assetId ? isPremod(asset.settings.moderation) :
isPremod(settings.moderation);
const premodEnabled = assetId
? isPremod(asset.settings.moderation)
: isPremod(settings.moderation);
const currentQueueConfig = Object.assign({}, this.props.queueConfig);
@@ -234,16 +299,21 @@ class ModerationContainer extends Component {
delete currentQueueConfig.premod;
}
return <Moderation
{...this.props}
getModPath={getModPath}
loadMore={this.loadMore}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
activeTab={this.activeTab}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
/>;
return (
<Moderation
{...this.props}
getModPath={getModPath}
loadMore={this.loadMore}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
activeTab={this.activeTab}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
selectedCommentId={this.props.selectedCommentId}
commentBelongToQueue={this.commentBelongToQueue}
cleanUpQueue={this.cleanUpQueue}
/>
);
}
}
const COMMENT_ADDED_SUBSCRIPTION = gql`
@@ -350,27 +420,57 @@ const commentConnectionFragment = gql`
${Comment.fragments.comment}
`;
const withModQueueQuery = withQuery(({queueConfig}) => gql`
const withModQueueQuery = withQuery(
({queueConfig}) => gql`
query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!, $nullStatuses: [COMMENT_STATUS!]) {
${Object.keys(queueConfig).map((queue) => `
${Object.keys(queueConfig).map(
(queue) => `
${queue}: comments(query: {
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
statuses: ${
queueConfig[queue].statuses
? `[${queueConfig[queue].statuses.join(', ')}],`
: '$nullStatuses'
}
${
queueConfig[queue].tags
? `tags: ["${queueConfig[queue].tags.join('", "')}"],`
: ''
}
${
queueConfig[queue].action_type
? `action_type: ${queueConfig[queue].action_type}`
: ''
}
asset_id: $asset_id,
sortOrder: $sortOrder
sortOrder: $sortOrder,
limit: 20,
}) {
...CoralAdmin_Moderation_CommentConnection
}
`)}
${Object.keys(queueConfig).map((queue) => `
`
)}
${Object.keys(queueConfig).map(
(queue) => `
${queue}Count: commentCount(query: {
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
statuses: ${
queueConfig[queue].statuses
? `[${queueConfig[queue].statuses.join(', ')}],`
: '$nullStatuses'
}
${
queueConfig[queue].tags
? `tags: ["${queueConfig[queue].tags.join('", "')}"],`
: ''
}
${
queueConfig[queue].action_type
? `action_type: ${queueConfig[queue].action_type}`
: ''
}
asset_id: $asset_id,
})
`)}
`
)}
asset(id: $asset_id) @skip(if: $allAssets) {
id
title
@@ -383,24 +483,29 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
organizationName
moderation
}
me {
id
}
...${getDefinitionName(Comment.fragments.root)}
}
${Comment.fragments.root}
${commentConnectionFragment}
`, {
options: (props) => {
const id = getAssetId(props);
return {
variables: {
asset_id: id,
sortOrder: props.moderation.sortOrder,
allAssets: id === null,
nullStatuses: null,
},
fetchPolicy: 'network-only'
};
},
});
`,
{
options: (props) => {
const id = getAssetId(props);
return {
variables: {
asset_id: id,
sortOrder: props.moderation.sortOrder,
allAssets: id === null,
nullStatuses: null,
},
fetchPolicy: 'network-only',
};
},
}
);
const mapStateToProps = (state) => ({
moderation: state.moderation,
@@ -408,22 +513,26 @@ const mapStateToProps = (state) => ({
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
toggleModal,
singleView,
hideShortcutsNote,
toggleStorySearch,
viewUserDetail,
setSortOrder,
storySearchChange,
clearState,
notify,
}, dispatch),
...bindActionCreators(
{
toggleModal,
singleView,
hideShortcutsNote,
toggleStorySearch,
viewUserDetail,
setSortOrder,
storySearchChange,
clearState,
notify,
selectCommentId,
},
dispatch
),
});
export default compose(
withQueueConfig(baseQueueConfig),
connect(mapStateToProps, mapDispatchToProps),
withSetCommentStatus,
withModQueueQuery,
withModQueueQuery
)(ModerationContainer);
@@ -16,16 +16,21 @@ function queueHasComment(root, queue, id) {
return root[queue].nodes.find((c) => c.id === id);
}
function removeCommentFromQueue(root, queue, id) {
function removeCommentFromQueue(root, queue, id, dangling = false) {
if (!queueHasComment(root, queue, id)) {
return root;
}
return update(root, {
const changes = {
[`${queue}Count`]: {$set: root[`${queue}Count`] - 1},
[queue]: {
};
if (!dangling) {
changes[queue] = {
nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)},
},
});
};
}
return update(root, changes);
}
function shouldCommentBeAdded(root, queue, comment, sortOrder) {
@@ -40,26 +45,46 @@ function shouldCommentBeAdded(root, queue, comment, sortOrder) {
: new Date(comment.created_at) >= cursor;
}
function addCommentToQueue(root, queue, comment, sortOrder) {
function addCommentToQueue(root, queue, comment, sortOrder, cleanup) {
if (queueHasComment(root, queue, comment.id)) {
return root;
}
const sortAlgo = sortOrder === 'ASC' ? ascending : descending;
const changes = {
[`${queue}Count`]: {$set: root[`${queue}Count`] + 1},
};
if (shouldCommentBeAdded(root, queue, comment, sortOrder)) {
const nodes = root[queue].nodes.concat(comment).sort(sortAlgo);
changes[queue] = {
nodes: {$set: nodes},
startCursor: {$set: nodes[0].created_at},
endCursor: {$set: nodes[nodes.length - 1].created_at},
};
if (!shouldCommentBeAdded(root, queue, comment, sortOrder)) {
return update(root, changes);
}
return update(root, changes);
const cursor = new Date(root[queue].startCursor);
const date = new Date(comment.created_at);
let append = sortOrder === 'ASC'
? date >= cursor
: date <= cursor;
const nodes = append
? root[queue].nodes.concat(comment)
: [comment].concat(...root[queue].nodes);
changes[queue] = {
nodes: {$set: nodes},
};
const next = update(root, changes);
if (!cleanup) {
return next;
}
return cleanUpQueue(next, queue, sortOrder);
}
function sortComments(nodes, sortOrder) {
const sortAlgo = sortOrder === 'ASC' ? ascending : descending;
return nodes.sort(sortAlgo);
}
/**
@@ -68,24 +93,92 @@ function addCommentToQueue(root, queue, comment, sortOrder) {
function getCommentQueues(comment, queueConfig) {
const queues = [];
Object.keys(queueConfig).forEach((key) => {
const {action_type, statuses, tags} = queueConfig[key];
let addToQueues = true;
if (statuses && statuses.indexOf(comment.status) === -1) {
addToQueues = false;
}
if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) {
addToQueues = false;
}
if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) {
addToQueues = false;
}
if (addToQueues) {
if (commentBelongToQueue(key, comment, queueConfig)) {
queues.push(key);
}
});
return queues;
}
/**
* Return whether or not the comment belongs to the queue.
*/
export function commentBelongToQueue(queue, comment, queueConfig) {
const {action_type, statuses, tags} = queueConfig[queue];
let belong = true;
if (statuses && statuses.indexOf(comment.status) === -1) {
belong = false;
}
if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) {
belong = false;
}
if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) {
belong = false;
}
return belong;
}
function isVisible(id) {
return !!document.getElementById(`comment_${id}`);
}
function isEndOfListVisible(root, queue) {
return root[queue].nodes.length === 0 || !!document.getElementById('end-of-comment-list');
}
function applyCommentChanges(root, comment, queueConfig) {
const queues = Object.keys(queueConfig);
for (let i = 0; i < queues.length; i++) {
const queue = queues[i];
const index = root[queue].nodes.findIndex(({id}) => id === comment.id);
if (index > -1) {
return update(root, {
[queue]: {
nodes: {
[index]: {$merge: comment},
},
},
});
}
}
return root;
}
/**
* Remove dangling comments, sort and resize queues.
* If queueConfig is omitted, dangling comments are not removed.
*/
export function cleanUpQueue(root, queue, sortOrder, queueConfig) {
let nodes = root[queue].nodes;
let hasNextPage = root[queue].hasNextPage;
if (!nodes.length) {
return root;
}
if (queueConfig) {
nodes = root[queue].nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig));
}
nodes = sortComments(
nodes,
sortOrder,
);
if (nodes.length > 100) {
nodes = nodes.slice(0, 100);
hasNextPage = true;
}
return update(root, {
[queue]: {
nodes: {$set: nodes},
endCursor: {$set: nodes[nodes.length - 1].created_at},
hasNextPage: {$set: hasNextPage},
},
});
}
/**
* Assimilate comment changes into current store.
* @param {Object} root current state of the store
@@ -113,25 +206,27 @@ export function handleCommentChange(root, comment, sortOrder, notify, queueConfi
Object.keys(queueConfig).forEach((queue) => {
if (nextQueues.indexOf(queue) >= 0) {
if (!queueHasComment(next, queue, comment.id)) {
next = addCommentToQueue(next, queue, comment, sortOrder);
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sortOrder)) {
next = addCommentToQueue(next, queue, comment, sortOrder, activeQueue !== queue);
if (notify && activeQueue === queue && isEndOfListVisible(root, queue)) {
showNotificationOnce();
}
}
} else if(queueHasComment(next, queue, comment.id)){
next = removeCommentFromQueue(next, queue, comment.id);
if (notify && activeQueue === queue) {
const dangling = activeQueue === queue && comment.status_history[comment.status_history.length - 1].assigned_by.id !== root.me.id;
next = removeCommentFromQueue(next, queue, comment.id, dangling);
if (notify && isVisible(comment.id)) {
showNotificationOnce();
}
}
if (
notify
&& queueHasComment(next, queue, comment.id)
&& activeQueue === queue
) {
if (notify && isVisible(comment.id)) {
showNotificationOnce();
}
// We need to apply every comment change, because we use
// batched subscription handler which bypasses apollo that would
// have done that for us.
next = applyCommentChanges(next, comment, queueConfig);
});
return next;
}
@@ -193,7 +193,12 @@ export default {
},
updateQueries: {
CoralEmbedStream_Embed: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
if (prev.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED' || comment.status === 'SYSTEM_WITHHELD') {
if (
prev.me.roles.indexOf('ADMIN') === -1 && prev.asset.settings.moderation === 'PRE' ||
comment.status === 'PREMOD' ||
comment.status === 'REJECTED' ||
comment.status === 'SYSTEM_WITHHELD'
) {
return prev;
}
return insertCommentIntoEmbedQuery(prev, comment);
+2 -16
View File
@@ -38,7 +38,7 @@ function applyToCommentsOrigin(root, callback) {
function findAndInsertComment(parent, comment) {
const isAsset = parent.__typename === 'Asset';
const [connectionField, countField, action] = isAsset
? ['comments', 'commentCount', '$unshift']
? ['comments', 'totalCommentCount', '$unshift']
: ['replies', 'replyCount', '$push'];
if (
@@ -67,19 +67,12 @@ function findAndInsertComment(parent, comment) {
}
export function insertCommentIntoEmbedQuery(root, comment) {
// Increase total comment count by one.
root = update(root, {
asset: {
totalCommentCount: {$apply: (c) => c + 1},
},
});
return applyToCommentsOrigin(root, (origin) => findAndInsertComment(origin, comment));
}
function findAndRemoveComment(parent, id) {
const [connectionField, countField] = parent.__typename === 'Asset'
? ['comments', 'commentCount']
? ['comments', 'totalCommentCount']
: ['replies', 'replyCount'];
const connection = parent[connectionField];
@@ -104,13 +97,6 @@ function findAndRemoveComment(parent, id) {
}
export function removeCommentFromEmbedQuery(root, id) {
// Decrease total comment by one.
root = update(root, {
asset: {
totalCommentCount: {$apply: (c) => c - 1},
},
});
return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id));
}
@@ -297,6 +297,7 @@ const fragments = {
ignoredUsers {
id
}
roles
}
settings {
organizationName
@@ -336,7 +337,6 @@ const fragments = {
charCount
requireEmailConfirmation
}
commentCount @skip(if: $hasComment)
totalCommentCount @skip(if: $hasComment)
comments(query: {limit: 10, excludeIgnored: $excludeIgnored, sortOrder: $sortOrder, sortBy: $sortBy}) @skip(if: $hasComment) {
nodes {
@@ -357,7 +357,6 @@ const fragments = {
const mapStateToProps = (state) => ({
auth: state.auth,
refetching: state.embed.refetching,
commentCountCache: state.stream.commentCountCache,
activeReplyBox: state.stream.activeReplyBox,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
+20 -7
View File
@@ -49,8 +49,9 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
memoized = null;
resolvedDocument = null;
lastNetworkStatus = null;
data = null;
name = '';
apolloData = null;
data = null;
// Pending subscription data.
subscriptionQueue = [];
@@ -83,6 +84,9 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
// Handle any pending susbcription data in the subscription queue at max once every second.
// Updates are batched in written into apollo in one go.
processSubscriptionQueue = throttle(() => {
if (!this.subscriptionQueue.length) {
return;
}
const variables = typeof this.wrappedOptions === 'function'
? this.wrappedOptions(this.props).variables
: this.wrappedOptions.variables;
@@ -151,6 +155,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
};
nextData(data) {
this.apolloData = data;
this.emitWhenNeeded(data);
// If data was previously set, we update it in a immutable way.
@@ -181,16 +186,24 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
variables: data.variables,
networkStatus: data.networkStatus,
loading: data.loading,
startPolling: data.startPolling,
stopPolling: data.stopPolling,
refetch: data.refetch,
updateQuery: data.updateQuery,
subscribeToMoreThrottled: this.subscribeToMoreThrottled,
startPolling: (...args) => {
return this.apolloData.startPolling(...args);
},
stopPolling: (...args) => {
return this.apolloData.stopPolling(...args);
},
updateQuery: (...args) => {
return this.apolloData.updateQuery(...args);
},
refetch: (...args) => {
return this.apolloData.refetch(...args);
},
subscribeToMore: (stmArgs) => {
const resolvedDocument = this.resolveDocument(stmArgs.document);
// Resolve document fragments before passing it to `apollo-client`.
return data.subscribeToMore({
return this.apolloData.subscribeToMore({
...stmArgs,
document: resolvedDocument,
onError: (err) => {
@@ -209,7 +222,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
{variables: lmArgs.variables});
// Resolve document fragments before passing it to `apollo-client`.
return data.fetchMore({
return this.apolloData.fetchMore({
...lmArgs,
query: resolvedDocument,
})
@@ -61,7 +61,7 @@ class ProfileContainer extends Component {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
}
if (loading) {
if (loading || !me) {
return <Spinner />;
}
+13
View File
@@ -0,0 +1,13 @@
.dialog {
}
:global(.backdrop) {
left: 0;
top: 0;
width: 100%;
height: 100%;
position: absolute;
background-color: black;
opacity: 0.1;
}
+11 -7
View File
@@ -2,6 +2,8 @@ import React, {Component} from 'react';
import PropTypes from 'prop-types';
import dialogPolyfill from 'dialog-polyfill';
import 'dialog-polyfill/dialog-polyfill.css';
import styles from './Dialog.css';
import {Portal} from 'react-portal';
export default class Dialog extends Component {
static propTypes = {
@@ -52,13 +54,15 @@ export default class Dialog extends Component {
const {children, className = '', onClose, onCancel, open, ...rest} = this.props; // eslint-disable-line
return (
<dialog
ref={(el) => { this.dialog = el; }}
className={`mdl-dialog ${className}`}
{...rest}
>
{children}
</dialog>
<Portal>
<dialog
ref={(el) => { this.dialog = el; }}
className={`mdl-dialog ${className} ${styles.dialog}`}
{...rest}
>
{children}
</dialog>
</Portal>
);
}
}
+77 -20
View File
@@ -16,17 +16,27 @@ const debug = require('debug')('talk:config');
//==============================================================================
const CONFIG = {
// WEBPACK indicates when webpack is currently building.
WEBPACK: process.env.WEBPACK === 'TRUE',
APOLLO_ENGINE_KEY: process.env.APOLLO_ENGINE_KEY || null,
ENABLE_TRACING: Boolean(process.env.APOLLO_ENGINE_KEY),
// EMAIL_SUBJECT_PREFIX is the string before emails in the subject.
EMAIL_SUBJECT_PREFIX: process.env.TALK_EMAIL_SUBJECT_PREFIX || '[Talk]',
// DEFAULT_LANG is the default language used for server sent emails and
// rendered text.
DEFAULT_LANG: process.env.TALK_DEFAULT_LANG || 'en',
// When TRUE, it ensures that database indexes created in core will not add
// indexes.
CREATE_MONGO_INDEXES: process.env.DISABLE_CREATE_MONGO_INDEXES !== 'TRUE',
// SETTINGS_CACHE_TIME is the time that we'll cache the settings in redis before
// fetching again.
SETTINGS_CACHE_TIME: ms(process.env.TALK_SETTINGS_CACHE_TIME || '1hr'),
//------------------------------------------------------------------------------
// JWT based configuration
//------------------------------------------------------------------------------
@@ -44,14 +54,19 @@ const CONFIG = {
// JWT_SIGNING_COOKIE_NAME will be the cookie set when cookies are issued.
// This defaults to the TALK_JWT_COOKIE_NAME value.
JWT_SIGNING_COOKIE_NAME: process.env.TALK_JWT_SIGNING_COOKIE_NAME || process.env.TALK_JWT_COOKIE_NAME || 'authorization',
JWT_SIGNING_COOKIE_NAME:
process.env.TALK_JWT_SIGNING_COOKIE_NAME ||
process.env.TALK_JWT_COOKIE_NAME ||
'authorization',
// JWT_COOKIE_NAMES declares the many cookie names used for verification.
JWT_COOKIE_NAMES: process.env.TALK_JWT_COOKIE_NAMES || null,
// JWT_CLEAR_COOKIE_LOGOUT specifies whether the named cookie should be
// cleared when the user is logged out.
JWT_CLEAR_COOKIE_LOGOUT: process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT ? process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT !== 'FALSE' : true,
JWT_CLEAR_COOKIE_LOGOUT: process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT
? process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT !== 'FALSE'
: true,
// JWT_DISABLE_AUDIENCE when TRUE will disable the audience claim (aud) from tokens.
JWT_DISABLE_AUDIENCE: process.env.TALK_JWT_DISABLE_AUDIENCE === 'TRUE',
@@ -92,7 +107,9 @@ const CONFIG = {
// HELMET_CONFIGURATION provides the entrypoint to override options for the
// helmet middleware used.
HELMET_CONFIGURATION: JSON.parse(process.env.TALK_HELMET_CONFIGURATION || '{}'),
HELMET_CONFIGURATION: JSON.parse(
process.env.TALK_HELMET_CONFIGURATION || '{}'
),
//------------------------------------------------------------------------------
// External database url's
@@ -111,22 +128,30 @@ const CONFIG = {
// REDIS_CLUSTER_CONFIGURATION contains the json string for the redis cluster
// configuration.
REDIS_CLUSTER_CONFIGURATION: process.env.TALK_REDIS_CLUSTER_CONFIGURATION || '[]',
REDIS_CLUSTER_CONFIGURATION:
process.env.TALK_REDIS_CLUSTER_CONFIGURATION || '[]',
// REDIS_RECONNECTION_BACKOFF_FACTOR is the factor that will be multiplied
// against the current attempt count inbetween attempts to connect to redis.
REDIS_RECONNECTION_BACKOFF_FACTOR: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_FACTOR || '500 ms'),
REDIS_RECONNECTION_BACKOFF_FACTOR: ms(
process.env.TALK_REDIS_RECONNECTION_BACKOFF_FACTOR || '500 ms'
),
// REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME is the minimum time used to delay
// before attempting to reconnect to redis.
REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME || '1 sec'),
REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME: ms(
process.env.TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME || '1 sec'
),
//------------------------------------------------------------------------------
// Server Config
//------------------------------------------------------------------------------
// Port to bind to.
PORT: process.env.TALK_PORT || process.env.PORT || (process.env.NODE_ENV === 'test' ? '3001' : '3000'),
PORT:
process.env.TALK_PORT ||
process.env.PORT ||
(process.env.NODE_ENV === 'test' ? '3001' : '3000'),
// The URL for this Talk Instance as viewable from the outside.
ROOT_URL: process.env.TALK_ROOT_URL || null,
@@ -150,7 +175,8 @@ const CONFIG = {
// Cache configuration
//------------------------------------------------------------------------------
CACHE_EXPIRY_COMMENT_COUNT: process.env.TALK_CACHE_EXPIRY_COMMENT_COUNT || '1hr',
CACHE_EXPIRY_COMMENT_COUNT:
process.env.TALK_CACHE_EXPIRY_COMMENT_COUNT || '1hr',
//------------------------------------------------------------------------------
// Recaptcha configuration
@@ -170,7 +196,9 @@ const CONFIG = {
SMTP_FROM_ADDRESS: process.env.TALK_SMTP_FROM_ADDRESS,
SMTP_HOST: process.env.TALK_SMTP_HOST,
SMTP_PASSWORD: process.env.TALK_SMTP_PASSWORD,
SMTP_PORT: process.env.TALK_SMTP_PORT ? parseInt(process.env.TALK_SMTP_PORT) : undefined,
SMTP_PORT: process.env.TALK_SMTP_PORT
? parseInt(process.env.TALK_SMTP_PORT)
: undefined,
SMTP_USERNAME: process.env.TALK_SMTP_USERNAME,
//------------------------------------------------------------------------------
@@ -179,7 +207,8 @@ const CONFIG = {
// DISABLE_AUTOFLAG_SUSPECT_WORDS is true when the suspect words that are
// matched should not be flagged.
DISABLE_AUTOFLAG_SUSPECT_WORDS: process.env.TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS === 'TRUE',
DISABLE_AUTOFLAG_SUSPECT_WORDS:
process.env.TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS === 'TRUE',
// TRUST_THRESHOLDS defines the thresholds used for automoderation.
TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:2,-1;flag:2,-1',
@@ -187,7 +216,8 @@ const CONFIG = {
// IGNORE_FLAGS_AGAINST_STAFF disables staff members from entering the
// reported queue from comments after this was enabled and from reports
// against the staff members user account.
IGNORE_FLAGS_AGAINST_STAFF: process.env.TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF !== 'TRUE',
IGNORE_FLAGS_AGAINST_STAFF:
process.env.TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF !== 'TRUE',
};
//==============================================================================
@@ -214,7 +244,9 @@ if (CONFIG.JWT_SECRETS) {
} else if (!CONFIG.JWT_SECRET) {
if (process.env.NODE_ENV === 'test') {
if (!CONFIG.JWT_ALG.startsWith('HS')) {
throw new Error('Providing a asymmetric signing/verfying algorithm without a corresponding secret is not permitted');
throw new Error(
'Providing a asymmetric signing/verfying algorithm without a corresponding secret is not permitted'
);
}
CONFIG.JWT_SECRET = 'keyboard cat';
@@ -243,7 +275,12 @@ if (CONFIG.JWT_COOKIE_NAMES) {
}
// Add in the default cookie names and strip duplicates.
CONFIG.JWT_COOKIE_NAMES = uniq(CONFIG.JWT_COOKIE_NAMES.concat([CONFIG.JWT_COOKIE_NAME, CONFIG.JWT_SIGNING_COOKIE_NAME]));
CONFIG.JWT_COOKIE_NAMES = uniq(
CONFIG.JWT_COOKIE_NAMES.concat([
CONFIG.JWT_COOKIE_NAME,
CONFIG.JWT_SIGNING_COOKIE_NAME,
])
);
//------------------------------------------------------------------------------
// External database url's
@@ -265,17 +302,25 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) {
// REDIS_CLUSTER_CONFIGURATION should be parsed when the cluster mode !== none.
if (CONFIG.REDIS_CLUSTER_MODE === 'CLUSTER') {
try {
CONFIG.REDIS_CLUSTER_CONFIGURATION = JSON.parse(CONFIG.REDIS_CLUSTER_CONFIGURATION);
CONFIG.REDIS_CLUSTER_CONFIGURATION = JSON.parse(
CONFIG.REDIS_CLUSTER_CONFIGURATION
);
} catch (err) {
throw new Error('TALK_REDIS_CLUSTER_CONFIGURATION is not valid JSON, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes');
throw new Error(
'TALK_REDIS_CLUSTER_CONFIGURATION is not valid JSON, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'
);
}
if (!Array.isArray(CONFIG.REDIS_CLUSTER_CONFIGURATION)) {
throw new Error('TALK_REDIS_CLUSTER_MODE is CLUSTER, but the TALK_REDIS_CLUSTER_CONFIGURATION is invalid, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes');
throw new Error(
'TALK_REDIS_CLUSTER_MODE is CLUSTER, but the TALK_REDIS_CLUSTER_CONFIGURATION is invalid, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'
);
}
if (CONFIG.REDIS_CLUSTER_CONFIGURATION.length === 0) {
throw new Error('TALK_REDIS_CLUSTER_CONFIGURATION must have at least one node specified in the cluster, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes');
throw new Error(
'TALK_REDIS_CLUSTER_CONFIGURATION must have at least one node specified in the cluster, see https://github.com/luin/ioredis#cluster for valid syntax of the list of cluster nodes'
);
}
}
@@ -296,7 +341,13 @@ CONFIG.RECAPTCHA_ENABLED =
CONFIG.RECAPTCHA_PUBLIC &&
CONFIG.RECAPTCHA_PUBLIC.length > 0;
debug(`reCAPTCHA is ${CONFIG.RECAPTCHA_ENABLED ? 'enabled' : 'disabled, required config is not present'}`);
debug(
`reCAPTCHA is ${
CONFIG.RECAPTCHA_ENABLED
? 'enabled'
: 'disabled, required config is not present'
}`
);
//------------------------------------------------------------------------------
// SMTP Server configuration
@@ -312,6 +363,12 @@ CONFIG.EMAIL_ENABLED =
CONFIG.SMTP_HOST &&
CONFIG.SMTP_HOST.length > 0;
debug(`Email is ${CONFIG.EMAIL_ENABLED ? 'enabled' : 'disabled, required config is not present'}`);
debug(
`Email is ${
CONFIG.EMAIL_ENABLED
? 'enabled'
: 'disabled, required config is not present'
}`
);
module.exports = CONFIG;
+7 -1
View File
@@ -460,4 +460,10 @@ Could be read as:
## TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF
When `TRUE`, staff members will have their accounts and comments moderated the
same as any other user in the system. (Default `FALSE`)
same as any other user in the system. (Default `FALSE`)
## TALK_EMAIL_SUBJECT_PREFIX
The prefix for the subject of emails sent. An email with the specified subject
of `Email Confirmation` would then be sent as `[Talk] Email Confirmation`.
(Default `[Talk]`)
+9 -1
View File
@@ -1,3 +1,6 @@
const debug = require('debug')('talk:graph:connectors');
const merge = require('lodash/merge');
// Errors.
const errors = require('../errors');
@@ -76,4 +79,9 @@ const connectors = {
},
};
module.exports = connectors;
module.exports = Plugins.get('server', 'connectors').reduce((defaultConnectors, {plugin, connectors: pluginConnectors}) => {
debug(`adding plugin '${plugin.name}'`);
// Merge in the plugin connectors.
return merge(defaultConnectors, pluginConnectors);
}, connectors);
+10 -6
View File
@@ -42,14 +42,15 @@ const decorateContextPlugins = (context, contextPlugins) => {
* Stores the request context.
*/
class Context {
constructor({user = null}) {
constructor(parent) {
// Generate a new context id for the request.
this.id = uuid.v4();
// Generate a new context id for the request if the parent doesn't provide
// one.
this.id = parent.id || uuid.v4();
// Load the current logged in user to `user`, otherwise this'll be null.
if (user) {
this.user = user;
// Load the current logged in user to `user`, otherwise this will be null.
if (parent.user) {
this.user = parent.user;
}
// Attach the connectors.
@@ -66,6 +67,9 @@ class Context {
// Bind the publish/subscribe to the context.
this.pubsub = pubsub.getClient();
// Bind the parent context.
this.parent = parent;
}
}
+1 -1
View File
@@ -162,7 +162,7 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu
// just added a new comment, hence the counts should be updated. We should
// perform these increments in the event that we do have a new comment that
// is approved or without a comment.
if (status === 'NONE' || status === 'APPROVED') {
if (status === 'NONE' || status === 'ACCEPTED') {
if (parent_id === null) {
Comments.parentCountByAssetID.incr(asset_id);
}
+12 -1
View File
@@ -1,4 +1,8 @@
const {makeExecutableSchema} = require('graphql-tools');
const {
makeExecutableSchema,
addSchemaLevelResolveFunction,
} = require('graphql-tools');
const debug = require('debug')('talk:graph:schema');
const {decorateWithHooks} = require('./hooks');
const {decorateWithErrorHandler} = require('./errorHandler');
@@ -14,4 +18,11 @@ decorateWithHooks(schema, plugins.get('server', 'hooks'));
// Handle errors like masking in production and mutation errors.
decorateWithErrorHandler(schema);
// For each schemaLevelResolveFunction, add it to the schema.
plugins.get('server', 'schemaLevelResolveFunction').forEach(({plugin, schemaLevelResolveFunction}) => {
debug(`added schemaLevelResolveFunction from plugin '${plugin.name}'`);
addSchemaLevelResolveFunction(schema, schemaLevelResolveFunction);
});
module.exports = schema;
+34 -3
View File
@@ -5,6 +5,7 @@ const debug = require('debug')('talk:graph:subscriptions');
const pubsub = require('../services/pubsub');
const schema = require('./schema');
const Context = require('./context');
const plugins = require('../services/plugins');
const {deserializeUser} = require('../services/subscriptions');
const setupFunctions = require('./setupFunctions');
@@ -16,16 +17,42 @@ const {
const {BASE_PATH} = require('../url');
const onConnect = ({token}, connection) => {
// Collect all the plugin hooks that should be executed onConnect and
// onDisconnect.
const hooks = plugins.get('server', 'websockets')
.map(({plugin, websockets}) => {
debug(`added websocket hooks ${Object.keys(websockets)} from plugin '${plugin.name}'`);
return websockets;
})
.reduce((hooks, {onConnect = null, onDisconnect = null}) => {
if (onConnect) {
hooks.onConnect.push(onConnect);
}
if (onDisconnect) {
hooks.onDisconnect.push(onDisconnect);
}
return hooks;
}, {
onConnect: [],
onDisconnect: [],
});
const onConnect = async (connectionParams, connection) => {
// Attach the token from the connection options if it was provided.
if (token) {
if (connectionParams.token) {
debug('token sent via onConnect, attaching to the headers of the upgrade request');
// Attach it to the upgrade request.
connection.upgradeReq.headers['authorization'] = `Bearer ${token}`;
connection.upgradeReq.headers['authorization'] = `Bearer ${connectionParams.token}`;
}
// Call all the hooks.
await Promise.all(hooks.onConnect.map((hook) => hook(connectionParams, connection)));
};
const onOperation = (parsedMessage, baseParams, connection) => {
@@ -52,6 +79,9 @@ const onOperation = (parsedMessage, baseParams, connection) => {
return baseParams;
};
const onDisconnect = (connection) =>
Promise.all(hooks.onDisconnect.map((hook) => hook(connection)));
/**
* This creates a new subscription manager.
*/
@@ -62,6 +92,7 @@ const createSubscriptionManager = (server) => new SubscriptionServer({
setupFunctions,
}),
onConnect,
onDisconnect,
onOperation,
keepAlive: ms(KEEP_ALIVE)
}, {
+1 -1
View File
@@ -285,7 +285,7 @@ da:
no_like_bio: "Jeg kan ikke lide denne biografi"
no_like_username: "Jeg kan ikke lide dette brugernavn"
other: "Andet"
permalink: "Link"
permalink: "Delen"
personal_info: "Denne kommentar afslører personligt identificerbare oplysninger"
post: "Post"
profile: "Profil"
+6 -1
View File
@@ -171,6 +171,11 @@ en:
minute: "minute"
minutes_plural: "minutes"
email:
suspended:
subject: "Your account has been suspended"
banned:
subject: "Your account has been banned"
body: "In accordance with The Coral Projects community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community."
confirm:
has_been_requested: "A email confirmation has been requested for the following account:"
to_confirm: "To confirm the account, please visit the following link:"
@@ -320,7 +325,7 @@ en:
no_like_bio: "I don't like this bio"
no_like_username: "I don't like this username"
other: Other
permalink: Link
permalink: Share
personal_info: "This comment reveals personally identifiable information"
post: Post
profile: Profile
+1 -1
View File
@@ -301,7 +301,7 @@ es:
no_like_bio: "No me gusta esta biografia"
no_like_username: "No me gusta este nombre de usuario"
other: Otro
permalink: Enlace
permalink: Compartir
personal_info: "Este comentario muestra información personal"
post: Publicar
profile: Perfil
+1 -1
View File
@@ -237,7 +237,7 @@ fr:
no_like_bio: "Je n'aime pas cette biographie"
no_like_username: "Je n'aime pas ce nom d'utilisateur"
other: Autre
permalink: Lien
permalink: Partager
personal_info: "Ce commentaire révèle des informations personnelles identifiables"
post: Publier
profile: Profil
+1 -1
View File
@@ -288,7 +288,7 @@ pt_BR:
no_like_bio: "Eu não gosto dessa descrição de perfil"
no_like_username: "Eu não gosto deste nome de usuário"
other: Outro
permalink: Link
permalink: Compartilhar
personal_info: "Este comentário revela informações de identificação pessoal"
post: Publicar
profile: Perfil
+6
View File
@@ -0,0 +1,6 @@
const i18n = require('../services/i18n');
module.exports = (req, res, next) => {
res.locals.t = i18n.request(req);
next();
};
+2 -2
View File
@@ -9,13 +9,13 @@ module.exports = {
globals_path: './test/e2e/globals',
selenium: {
start_process: true,
server_path: 'node_modules/selenium-standalone/.selenium/selenium-server/3.6.0-server.jar',
server_path: 'node_modules/selenium-standalone/.selenium/selenium-server/3.7.1-server.jar',
log_path: './test/e2e/',
host: '127.0.0.1',
port: 6666,
cli_args: {
'webdriver.chrome.driver': 'node_modules/selenium-standalone/.selenium/chromedriver/2.33-x64-chromedriver',
'webdriver.gecko.driver': 'node_modules/selenium-standalone/.selenium/geckodriver/0.19.0-x64-geckodriver',
'webdriver.gecko.driver': 'node_modules/selenium-standalone/.selenium/geckodriver/0.19.1-x64-geckodriver',
}
},
test_settings: {
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "3.8.2",
"version": "3.9.0",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"private": true,
@@ -71,7 +71,6 @@
"babel-preset-es2015": "6.24.1",
"babel-preset-react": "^6.23.0",
"bcryptjs": "^2.4.3",
"body-parser": "1.18.2",
"bowser": "^1.7.2",
"brotli-webpack-plugin": "^0.5.0",
"cli-table": "^0.3.1",
@@ -153,6 +152,7 @@
"react-mdl": "^1.11.0",
"react-mdl-selectfield": "^0.2.0",
"react-paginate": "^5.0.0",
"react-portal": "^4.1.2",
"react-recaptcha": "^2.2.6",
"react-redux": "^4.4.5",
"react-router": "^3.0.0",
@@ -160,6 +160,7 @@
"react-test-renderer": "15.5",
"react-toastify": "^1.5.0",
"react-transition-group": "^1.1.3",
"react-virtualized": "9.13.0",
"recompose": "^0.23.1",
"redux": "^3.6.0",
"redux-thunk": "^2.1.0",
+4 -3
View File
@@ -87,11 +87,12 @@ export default (tag, options = {}) => hoistStatics((WrappedComponent) => {
}
render() {
const {root, asset, comment, user, config} = this.props;
const {root, asset, comment, user, config, ...rest} = this.props;
const alreadyTagged = isTagged(comment.tags, TAG);
return <WrappedComponent
{...rest}
root={root}
asset={asset}
comment={comment}
@@ -134,9 +135,9 @@ export default (tag, options = {}) => hoistStatics((WrappedComponent) => {
${fragments.comment ? fragments.comment : ''}
`
}),
connect(mapStateToProps, mapDispatchToProps),
withAddTag,
withRemoveTag
withRemoveTag,
connect(mapStateToProps, mapDispatchToProps),
);
WithTags.displayName = `WithTags(${getDisplayName(WrappedComponent)})`;
+7 -2
View File
@@ -55,7 +55,12 @@ const hookSchemas = {
loaders: Joi.func().maxArity(1),
mutators: Joi.func().maxArity(1),
resolvers: Joi.object().pattern(/\w/, Joi.object().pattern(/(?:__resolveType|\w+)/, Joi.func())),
typeDefs: Joi.string()
typeDefs: Joi.string(),
schemaLevelResolveFunction: Joi.func(),
websockets: Joi.object({
onConnect: Joi.func(),
onDisconnect: Joi.func(),
}),
};
/**
@@ -172,7 +177,7 @@ class PluginSection {
if (this.required) {
return;
}
this.required = true;
this.plugins.forEach((plugin) => {
@@ -0,0 +1,16 @@
import {OPEN_FEATURED_DIALOG, CLOSE_FEATURED_DIALOG} from './constants';
export const openFeaturedDialog = (comment, asset) => ({
type: OPEN_FEATURED_DIALOG,
comment: {
id: comment.id,
tags: comment.tags,
},
asset: {
id: asset.id,
},
});
export const closeFeaturedDialog = () => ({
type: CLOSE_FEATURED_DIALOG,
});
@@ -0,0 +1,41 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 400px;
top: 50%;
transform: translateY(-50%);
padding: 20px;
border-radius: 4px;
}
.header {
color: black;
font-size: 1.5em;
font-weight: 500;
margin: 0 0 8px 0;
}
.close {
display: block;
position: absolute;
top: 24px;
right: 20px;
}
.cancel {
margin-right: 5px;
}
.perform {
min-width: 90px;
}
.buttons {
margin-top: 8px;
margin-bottom: 6px;
text-align: right;
}
.content {
margin-bottom: 20px;
}
@@ -0,0 +1,52 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import {Dialog} from 'coral-ui';
import styles from './FeaturedDialog.css';
import {t} from 'plugin-api/beta/client/services';
import Button from 'coral-ui/components/Button';
const FeaturedDialog = ({showFeaturedDialog, closeFeaturedDialog, postTag}) => {
const onPerform = async () => {
await postTag();
await closeFeaturedDialog();
};
return (
<Dialog
className={cn(styles.dialog, 'talk-featured-dialog')}
id="talkFeaturedDialog"
open={showFeaturedDialog}
onCancel={closeFeaturedDialog} >
<span className={styles.close} onClick={closeFeaturedDialog}>×</span>
<h2 className={styles.header}>{t('talk-plugin-featured-comments.feature_comment')}</h2>
<div className={styles.content}>
{t('talk-plugin-featured-comments.are_you_sure')}
</div>
<div className={styles.buttons}>
<Button
className={cn(styles.cancel, 'talk-featured-dialog-button-cancel')}
cStyle="cancel"
onClick={closeFeaturedDialog}
raised >
{t('talk-plugin-featured-comments.cancel')}
</Button>
<Button
className={cn(styles.perform, 'talk-featured-dialog-button-confirm')}
cStyle="black"
onClick={onPerform}
raised >
{t('talk-plugin-featured-comments.yes_feature_comment')}
</Button>
</div>
</Dialog>
);
};
FeaturedDialog.propTypes = {
showFeaturedDialog: PropTypes.bool.isRequired,
closeFeaturedDialog: PropTypes.func.isRequired,
};
export default FeaturedDialog;
@@ -1,9 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './ModTag.css';
import {t} from 'plugin-api/beta/client/services';
import {Icon} from 'plugin-api/beta/client/components/ui';
import {getErrorMessages} from 'plugin-api/beta/client/utils';
export default class ModTag extends React.Component {
constructor() {
@@ -29,17 +29,12 @@ export default class ModTag extends React.Component {
});
}
postTag = async () => {
try {
await this.props.postTag();
}
catch(err) {
this.props.notify('error', getErrorMessages(err));
}
openFeaturedDialog = (comment, asset) => {
this.props.openFeaturedDialog(comment, asset);
}
render() {
const {alreadyTagged, deleteTag} = this.props;
const {alreadyTagged, deleteTag, comment, asset} = this.props;
return alreadyTagged ? (
<span className={cn(styles.tag, styles.featured)}
@@ -51,10 +46,19 @@ export default class ModTag extends React.Component {
</span>
) : (
<span className={cn(styles.tag, {[styles.featured]: alreadyTagged})}
onClick={this.postTag} >
onClick={() => this.openFeaturedDialog(comment, asset)} >
<Icon name="star_outline" className={cn(styles.tagIcon)} />
{alreadyTagged ? t('talk-plugin-featured-comments.featured') : t('talk-plugin-featured-comments.feature')}
</span>
);
}
}
ModTag.propTypes = {
alreadyTagged: PropTypes.bool,
deleteTag: PropTypes.func,
notify: PropTypes.func,
openFeaturedDialog: PropTypes.func,
comment: PropTypes.object,
asset: PropTypes.object,
};
@@ -0,0 +1,4 @@
const prefix = 'TALK_FEATURED_COMMENTS_ACTIONS';
export const OPEN_FEATURED_DIALOG = `${prefix}_OPEN_FEATURED_DIALOG`;
export const CLOSE_FEATURED_DIALOG = `${prefix}_CLOSE_FEATURED_DIALOG`;
@@ -0,0 +1,23 @@
import {compose} from 'react-apollo';
import {bindActionCreators} from 'redux';
import FeaturedDialog from '../components/FeaturedDialog';
import {withTags, connect} from 'plugin-api/beta/client/hocs';
import {closeFeaturedDialog} from '../actions';
const mapStateToProps = ({talkPluginFeaturedComments: state}) => ({
showFeaturedDialog: state.showFeaturedDialog,
comment: state.comment,
asset: state.asset,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
closeFeaturedDialog,
}, dispatch);
const enhance = compose(
connect(mapStateToProps, mapDispatchToProps),
withTags('featured'),
);
export default enhance(FeaturedDialog);
@@ -65,6 +65,14 @@ const COMMENT_FEATURED_SUBSCRIPTION = gql`
commentFeatured(asset_id: $assetId) {
comment {
...${getDefinitionName(Comment.fragments.comment)}
status_history {
type
created_at
assigned_by {
id
username
}
}
}
user {
id
@@ -2,11 +2,13 @@ import ModTag from '../components/ModTag';
import {withTags, connect} from 'plugin-api/beta/client/hocs';
import {gql, compose} from 'react-apollo';
import {bindActionCreators} from 'redux';
import {openFeaturedDialog} from '../actions';
import {notify} from 'plugin-api/beta/client/actions/notification';
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
notify,
openFeaturedDialog,
}, dispatch);
const fragments = {
@@ -24,4 +26,3 @@ const enhance = compose(
);
export default enhance(ModTag);
@@ -6,19 +6,22 @@ import update from 'immutability-helper';
import ModTag from './containers/ModTag';
import ModActionButton from './containers/ModActionButton';
import ModSubscription from './containers/ModSubscription';
import FeaturedDialog from './containers/FeaturedDialog';
import {gql} from 'react-apollo';
import reducer from './reducer';
import {findCommentInEmbedQuery} from 'coral-embed-stream/src/graphql/utils';
import {prependNewNodes} from 'plugin-api/beta/client/utils';
export default {
translations,
reducer,
slots: {
streamTabsPrepend: [Tab],
streamTabPanes: [TabPane],
commentInfoBar: [Tag],
moderationActions: [ModActionButton],
adminModeration: [ModSubscription],
adminModeration: [ModSubscription, FeaturedDialog],
adminCommentInfoBar: [ModTag],
},
mutations: {
@@ -0,0 +1,32 @@
import {OPEN_FEATURED_DIALOG, CLOSE_FEATURED_DIALOG} from './constants';
const initialState = {
showFeaturedDialog: false,
comment: {
id: null,
tags: []
},
asset: {
id: null,
},
};
export default function reducer(state = initialState, action) {
switch (action.type) {
case OPEN_FEATURED_DIALOG:
return {
...state,
comment: action.comment,
asset: action.asset,
showFeaturedDialog: true,
};
case CLOSE_FEATURED_DIALOG:
return {
...state,
featuredCommentId: null,
showFeaturedDialog: false,
};
default :
return state;
}
}
@@ -9,6 +9,10 @@ en:
notify_self_featured: 'The comment from {0} is now featured and approved'
notify_featured: '{0} featured and approved comment "{1}"'
notify_unfeatured: '{0} unfeatured comment "{1}"'
feature_comment: Feature comment?
are_you_sure: Are you sure you would like to feature this comment?
cancel: Cancel
yes_feature_comment: Yes, feature comment
es:
talk-plugin-featured-comments:
un_feature: Desmarcar
@@ -17,3 +21,7 @@ es:
featured_comments: Comentarios Remarcados
go_to_conversation: Ir al comentario
tooltip_description: Comentarios seleccionados por nuestro equipo que valen la pena ser leidos
feature_comment: Destacar comentario?
are_you_sure: Está seguro que desea destacar este comentario?
cancel: Cancelar
yes_feature_comment: Si, destacar comentario
+7 -7
View File
@@ -1,13 +1,12 @@
const SetupService = require('../services/setup');
const apollo = require('apollo-server-express');
const authentication = require('../middleware/authentication');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const debug = require('debug')('talk:routes');
const enabled = require('debug').enabled;
const errors = require('../errors');
const express = require('express');
const i18n = require('../services/i18n');
const i18n = require('../middleware/i18n');
const path = require('path');
const plugins = require('../services/plugins');
const staticTemplate = require('../middleware/staticTemplate');
@@ -41,6 +40,9 @@ if (!DISABLE_STATIC_SERVER) {
}));
}
// Add the i18n middleware to all routes.
router.use(i18n);
//==============================================================================
// STATIC ROUTES
//==============================================================================
@@ -56,7 +58,7 @@ router.use('/embed', staticTemplate, require('./embed'));
router.use(cookieParser());
// Parse the body json if it's there.
router.use(bodyParser.json());
router.use(express.json());
const passportDebug = require('debug')('talk:passport');
@@ -100,12 +102,12 @@ if (process.env.NODE_ENV !== 'production') {
}
router.use('/api/v1', require('./api'));
//==============================================================================
// ROUTES
//==============================================================================
router.use('/api/v1', require('./api'));
// Development routes.
if (process.env.NODE_ENV !== 'production') {
router.use('/assets', staticTemplate, require('./assets'));
@@ -175,8 +177,6 @@ router.use('/', (err, req, res, next) => {
console.error(err);
}
i18n.init(req);
if (err instanceof errors.APIError) {
res.status(err.status);
res.render('error', {
+11 -2
View File
@@ -2,17 +2,26 @@
REPORTS_FOLDER=${CIRCLE_TEST_REPORTS:-./test/e2e/reports}
CIRCLE_BRANCH=${CIRCLE_BRANCH:-master}
E2E_DISABLE=${E2E_DISABLE:-false}
# Amount of retries before failure.
E2E_MAX_RETRIES=${E2E_MAX_RETRIES:-1}
# Timeout for WaitForConditions.
E2E_WAIT_FOR_TIMEOUT=${E2E_WAIT_FOR_TIMEOUT:-10000}
# Safari >= 8 has issues connecting to browserstack-local. Safari < 8 is too old.
# IE 64bit has issues with receiving keyboard input. Let's wait for them to fix it.
BROWSERS="chrome,firefox,edge" #ie safari
E2E_BROWSERS=${E2E_BROWSERS:-chrome,firefox,edge} #ie safari
if [[ "${E2E_DISABLE}" == "true" ]]; then
echo E2E is disabled.
exit
fi
if [[ "${CIRCLE_BRANCH}" == "master" && -n "$BROWSERSTACK_KEY" ]]; then
echo Testing on browserstack
yarn e2e --reports-folder "$REPORTS_FOLDER" --bs-key "$BROWSERSTACK_KEY" --retries "$E2E_MAX_RETRIES" --browsers $BROWSERS
yarn e2e --reports-folder "$REPORTS_FOLDER" --bs-key "$BROWSERSTACK_KEY" --retries "$E2E_MAX_RETRIES" --timeout "$E2E_WAIT_FOR_TIMEOUT" --browsers "$E2E_BROWSERS"
else
# When browserstack is not available test locally using chrome headless.
echo Testing locally
+7 -3
View File
@@ -59,7 +59,7 @@ function seleniumInstall() {
});
}
function nightwatch(env, config, reportsFolder, browserstack) {
function nightwatch(env, config, reportsFolder, browserstack, timeout) {
return new Promise((resolve, reject) => {
try {
const nw = childProcess.spawn(
@@ -71,6 +71,7 @@ function nightwatch(env, config, reportsFolder, browserstack) {
'BROWSERSTACK_KEY': browserstack.key,
'BROWSERSTACK_USER': browserstack.user,
'REPORTS_FOLDER': `${reportsFolder}/${env}`,
'WAIT_FOR_TIMEOUT': timeout,
}),
stdio: 'inherit',
});
@@ -111,13 +112,13 @@ function printSection(txt) {
console.log('*****************************'.magenta);
}
async function runBrowserTests(browsers, config, retries = 1, reportsFolder, browserstack) {
async function runBrowserTests(browsers, config, retries = 1, reportsFolder, browserstack, timeout) {
const succeeded = {};
for (let browser of browsers) {
for (let t = 0; t < retries + 1; t++) {
try {
printSection(`e2e test for ${browser} #${t}`);
await nightwatch(browser, config, reportsFolder, browserstack);
await nightwatch(browser, config, reportsFolder, browserstack, timeout);
succeeded[browser] = t;
console.log(`\n==> Succeeded e2e for ${browser} #${t}\n`.green);
break;
@@ -143,6 +144,7 @@ async function start(program) {
const date = new Date().toISOString()
.replace(/[T.]/g, '-')
.replace(/:/g, '');
const timeout = program.timeout;
const reportsFolder = `${program.reportsFolder}/${date}`;
let exitCode = 0;
let config = 'nightwatch.conf.js';
@@ -175,6 +177,7 @@ async function start(program) {
retries,
reportsFolder,
browserstack,
timeout,
);
if (!succeeded) {
exitCode = 1;
@@ -202,6 +205,7 @@ program
.option('-r, --retries [number]', 'Number of retries before failing', '1')
.option('--headless', 'Start in headless mode for local e2e')
.option('--reports-folder [folder]', 'Reports folder', './test/e2e/reports')
.option('--timeout [number]', 'Timeout for WaitForConditions', '10000')
.parse(process.argv);
start(program);
+1 -1
View File
@@ -1,3 +1,3 @@
<p><%= t('email.confirm.has_been_requested') %> <b><%= email %></b>.</p>
<p><%= t('email.confirm.to_confirm') %> <a href="<%= BASE_URL %>admin/confirm-email#<%= token %>">Confirm Email</a></p>
<p><%= t('email.confirm.to_confirm') %> <a href="<%= BASE_URL %>admin/confirm-email#<%= token %>"><%= t('email.confirm.confirm_email') %></a></p>
<p><%= t('email.confirm.if_you_did_not') %></p>
+77 -43
View File
@@ -1,57 +1,91 @@
const has = require('lodash/has');
const get = require('lodash/get');
const yaml = require('yamljs');
const da = yaml.load('./locales/da.yml');
const es = yaml.load('./locales/es.yml');
const en = yaml.load('./locales/en.yml');
const fr = yaml.load('./locales/fr.yml');
const pt_BR = yaml.load('./locales/pt_BR.yml');
const fs = require('fs');
const path = require('path');
const debug = require('debug')('talk:services:i18n');
const accepts = require('accepts');
const _ = require('lodash');
const yaml = require('yamljs');
const plugins = require('./plugins');
const {DEFAULT_LANG} = require('../config');
// default language
let defaultLanguage = 'en';
let language = defaultLanguage;
const languages = ['en', 'da', 'es', 'fr', 'pt_BR'];
const resolve = (...paths) => path.resolve(path.join(__dirname, '..', 'locales', ...paths));
const translations = Object.assign(en, es, fr, pt_BR, da);
// Load all the translations.
let translations = fs.readdirSync(resolve())
// Resolve all the filenames relative the the locales directory.
.map((filename) => resolve(filename))
// Translations are only yml/yaml files.
.filter((filename) => /\.(yaml|yml)$/.test(filename))
// Load the translation files from disk.
.map((filename) => fs.readFileSync(filename, 'utf8'))
// Load the translation files.
.reduce((packs, contents) => {
const pack = yaml.parse(contents);
return _.merge(packs, pack);
}, {});
// Create a list of all supported translations.
const languages = Object.keys(translations);
let loadedPluginTranslations = false;
const loadPluginTranslations = () => {
if (loadedPluginTranslations) {
return;
}
// Load the plugin translations.
plugins.get('server', 'translations').forEach(({plugin, translations: filename}) => {
debug(`added plugin '${plugin.name}'`);
const pack = yaml.parse(fs.readFileSync(filename, 'utf8'));
translations = _.merge(translations, pack);
});
loadedPluginTranslations = true;
};
const t = (language) => (key, ...replacements) => {
// Loads the translations into the translations array from plugins. This is
// done lazily to ensure that we don't have an import cycle.
loadPluginTranslations();
// Check if the translation exists on the object.
if (_.has(translations[language], key)) {
// Get the translation value.
let translation = _.get(translations[language], key);
// Replace any {n} with the arguments passed to this method.
replacements.forEach((str, n) => {
translation = translation.replace(new RegExp(`\\{${n}\\}`, 'g'), str);
});
return translation;
} else {
console.warn(`${key} language key not set`);
return key;
}
};
/**
* Exposes a service object to allow translations.
* @type {Object}
*/
const i18n = {
/**
* Create the new Task kue.
*/
init(req) {
request(req) {
const lang = accepts(req).language(languages);
language = lang ? lang : defaultLanguage;
},
/**
* Translates a key.
*/
t(key, ...replacements) {
if (has(translations[language], key)) {
let translation = get(translations[language], key);
// replace any {n} with the arguments passed to this method
replacements.forEach((str, i) => {
translation = translation.replace(new RegExp(`\\{${i}\\}`, 'g'), str);
});
return translation;
} else {
console.warn(`${key} language key not set`);
return key;
}
const language = lang ? lang : DEFAULT_LANG;
return t(language);
},
t: t(DEFAULT_LANG),
};
module.exports = i18n;
+15 -4
View File
@@ -9,7 +9,8 @@ const kue = require('kue');
// singleton Queue instance. So you can configure and use only a single Queue
// object within your node.js process.
let queue = null;
const getQueue = () => {
let isManaging = false;
const getQueue = ({managed = false} = {}) => {
if (queue) {
return queue;
}
@@ -21,8 +22,16 @@ const getQueue = () => {
}
});
// Watch for stuck jobs to manage.
queue.watchStuckJobs(1000);
// If this is a managed queue, and we aren't managing yet, then start the
// management.
if (managed && !isManaging) {
// Watch for stuck jobs to manage.
queue.watchStuckJobs(60000);
// Mark that we've now started management routines.
isManaging = true;
}
return queue;
};
@@ -67,7 +76,9 @@ class Task {
* Process jobs for the queue.
*/
process(callback) {
return getQueue().process(this.name, callback);
// Get the queue in managed mode.
return getQueue({managed: true}).process(this.name, callback);
}
/**
+6 -5
View File
@@ -13,7 +13,8 @@ const {
SMTP_USERNAME,
SMTP_PORT,
SMTP_PASSWORD,
SMTP_FROM_ADDRESS
SMTP_FROM_ADDRESS,
EMAIL_SUBJECT_PREFIX,
} = require('../config');
// load all the templates as strings
@@ -95,12 +96,12 @@ const mailer = module.exports = {
}
// Prefix the subject with `[Talk]`.
subject = `[Talk] ${subject}`;
subject = `${EMAIL_SUBJECT_PREFIX} ${subject}`;
attachLocals(locals);
// Attach the templating function.
locals['t'] = i18n.t;
// Attach the translation function.
locals.t = i18n.t;
return Promise.all([
@@ -112,7 +113,7 @@ const mailer = module.exports = {
])
.then(([html, text]) => {
// Create the job.
// Create the job.
return mailer.task.create({
title: 'Mail',
message: {
+2 -1
View File
@@ -2,6 +2,7 @@ const SettingModel = require('../models/setting');
const cache = require('./cache');
const errors = require('../errors');
const {dotize} = require('./utils');
const {SETTINGS_CACHE_TIME} = require('../config');
/**
* The selector used to uniquely identify the settings document.
@@ -35,7 +36,7 @@ module.exports = class SettingsService {
if (process.env.NODE_ENV === 'production') {
// When in production, wrap the settings retrieval with a cache.
const settings = await cache.h.wrap('settings', fields, 60, () => retrieve(fields));
const settings = await cache.h.wrap('settings', fields, SETTINGS_CACHE_TIME / 1000, () => retrieve(fields));
return new SettingModel(settings);
}
+274 -189
View File
@@ -12,13 +12,9 @@ const {
} = require('./events/constants');
const events = require('./events');
const {
ROOT_URL
} = require('../config');
const {ROOT_URL} = require('../config');
const {
jwt: JWT_SECRET
} = require('../secrets');
const {jwt: JWT_SECRET} = require('../secrets');
const debug = require('debug')('talk:services:users');
@@ -43,12 +39,15 @@ const SALT_ROUNDS = 10;
// Create a redis client to use for authentication.
const Limit = require('./limit');
const loginRateLimiter = new Limit('loginAttempts', RECAPTCHA_INCORRECT_TRIGGER, RECAPTCHA_WINDOW);
const loginRateLimiter = new Limit(
'loginAttempts',
RECAPTCHA_INCORRECT_TRIGGER,
RECAPTCHA_WINDOW
);
// UsersService is the interface for the application to interact with the
// UserModel through.
class UsersService {
/**
* Returns a user (if found) for the given email address.
*/
@@ -57,9 +56,9 @@ class UsersService {
profiles: {
$elemMatch: {
id: email.toLowerCase(),
provider: 'local'
}
}
provider: 'local',
},
},
});
}
@@ -85,22 +84,26 @@ class UsersService {
}
static async setSuspensionStatus(id, until, assignedBy = null, message) {
let user = await UserModel.findOneAndUpdate({id}, {
$set: {
'status.suspension.until': until
let user = await UserModel.findOneAndUpdate(
{id},
{
$set: {
'status.suspension.until': until,
},
$push: {
'status.suspension.history': {
until,
assigned_by: assignedBy,
message,
created_at: Date.now(),
},
},
},
$push: {
'status.suspension.history': {
until,
assigned_by: assignedBy,
message,
created_at: Date.now()
}
{
new: true,
runValidators: true,
}
}, {
new: true,
runValidators: true
});
);
if (user === null) {
user = await UserModel.findOne({id});
if (user === null) {
@@ -112,45 +115,53 @@ class UsersService {
// check if the date is within 1 second of the time we're trying to set.
if (
user.status.suspension.until === until ||
(
user.status.suspension.until.getTime() > until.getTime() - 1000 &&
user.status.suspension.until.getTime() < until.getTime() + 1000
)
(user.status.suspension.until.getTime() > until.getTime() - 1000 &&
user.status.suspension.until.getTime() < until.getTime() + 1000)
) {
return user;
}
throw new Error('suspension status change edit failed for an unknown reason');
throw new Error(
'suspension status change edit failed for an unknown reason'
);
}
// Emit that the user username status was changed.
await events.emitAsync(USERS_SUSPENSION_CHANGE, user, {until, message, assignedBy});
await events.emitAsync(USERS_SUSPENSION_CHANGE, user, {
until,
message,
assignedBy,
});
return user;
}
static async setBanStatus(id, status, assignedBy = null, message) {
let user = await UserModel.findOneAndUpdate({
id,
status: {
$ne: status
}
}, {
$set: {
'status.banned.status': status
let user = await UserModel.findOneAndUpdate(
{
id,
status: {
$ne: status,
},
},
$push: {
'status.banned.history': {
status,
assigned_by: assignedBy,
message,
created_at: Date.now()
}
{
$set: {
'status.banned.status': status,
},
$push: {
'status.banned.history': {
status,
assigned_by: assignedBy,
message,
created_at: Date.now(),
},
},
},
{
new: true,
runValidators: true,
}
}, {
new: true,
runValidators: true
});
);
if (user === null) {
user = await UserModel.findOne({id});
if (user === null) {
@@ -165,31 +176,39 @@ class UsersService {
}
// Emit that the user ban status was changed.
await events.emitAsync(USERS_BAN_CHANGE, user, {status, assignedBy, message});
await events.emitAsync(USERS_BAN_CHANGE, user, {
status,
assignedBy,
message,
});
return user;
}
static async setUsernameStatus(id, status, assignedBy = null) {
let user = await UserModel.findOneAndUpdate({
id,
status: {
$ne: status
}
}, {
$set: {
'status.username.status': status
let user = await UserModel.findOneAndUpdate(
{
id,
status: {
$ne: status,
},
},
$push: {
'status.username.history': {
status,
assigned_by: assignedBy,
created_at: Date.now()
}
{
$set: {
'status.username.status': status,
},
$push: {
'status.username.history': {
status,
assigned_by: assignedBy,
created_at: Date.now(),
},
},
},
{
new: true,
}
}, {
new: true
});
);
if (user === null) {
user = await UserModel.findOne({id});
if (user === null) {
@@ -200,41 +219,57 @@ class UsersService {
return user;
}
throw new Error('username status change edit failed for an unknown reason');
throw new Error(
'username status change edit failed for an unknown reason'
);
}
// Emit that the user username status was changed.
await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, {status, assignedBy});
await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, {
status,
assignedBy,
});
return user;
}
static async _setUsername(id, username, fromStatus, toStatus, assignedBy, resetAllowed = false) {
static async _setUsername(
id,
username,
fromStatus,
toStatus,
assignedBy,
resetAllowed = false
) {
try {
const query = {
id,
'status.username.status': fromStatus
'status.username.status': fromStatus,
};
if (!resetAllowed) {
query.username = {$ne: username};
}
let user = await UserModel.findOneAndUpdate(query, {
$set: {
username,
lowercaseUsername: username.toLowerCase(),
'status.username.status': toStatus,
let user = await UserModel.findOneAndUpdate(
query,
{
$set: {
username,
lowercaseUsername: username.toLowerCase(),
'status.username.status': toStatus,
},
$push: {
'status.username.history': {
status: toStatus,
assigned_by: assignedBy,
created_at: Date.now(),
},
},
},
$push: {
'status.username.history': {
status: toStatus,
assigned_by: assignedBy,
created_at: Date.now()
}
{
new: true,
}
}, {
new: true
});
);
if (!user) {
user = await UsersService.findById(id);
if (user === null) {
@@ -266,11 +301,24 @@ class UsersService {
}
static async setUsername(id, username, assignedBy) {
return UsersService._setUsername(id, username, 'UNSET', 'SET', assignedBy, true);
return UsersService._setUsername(
id,
username,
'UNSET',
'SET',
assignedBy,
true
);
}
static async changeUsername(id, username, assignedBy) {
return UsersService._setUsername(id, username, 'REJECTED', 'CHANGED', assignedBy);
return UsersService._setUsername(
id,
username,
'REJECTED',
'CHANGED',
assignedBy
);
}
/**
@@ -294,18 +342,21 @@ class UsersService {
* Sets or unsets the recaptcha_required flag on a user's local profile.
*/
static flagForRecaptchaRequirement(email, required) {
return UserModel.update({
profiles: {
$elemMatch: {
id: email.toLowerCase(),
provider: 'local'
}
return UserModel.update(
{
profiles: {
$elemMatch: {
id: email.toLowerCase(),
provider: 'local',
},
},
},
{
$set: {
'profiles.$.metadata.recaptcha_required': required,
},
}
}, {
$set: {
'profiles.$.metadata.recaptcha_required': required
}
});
);
}
/**
@@ -320,11 +371,10 @@ class UsersService {
static mergeUsers(dstUserID, srcUserID) {
let srcUser, dstUser;
return Promise
.all([
UserModel.findOne({id: dstUserID}).exec(),
UserModel.findOne({id: srcUserID}).exec()
])
return Promise.all([
UserModel.findOne({id: dstUserID}).exec(),
UserModel.findOne({id: srcUserID}).exec(),
])
.then((users) => {
dstUser = users[0];
srcUser = users[1];
@@ -353,9 +403,9 @@ class UsersService {
profiles: {
$elemMatch: {
id,
provider
}
}
provider,
},
},
});
if (user) {
return user;
@@ -375,10 +425,10 @@ class UsersService {
username: {
status: 'UNSET',
history: {
status: 'UNSET'
}
}
}
status: 'UNSET',
},
},
},
});
// Save the user in the database.
@@ -396,22 +446,28 @@ class UsersService {
* @param {String} email the email for the user to send the email to
*/
static async sendEmailConfirmation(user, email, redirectURI = ROOT_URL) {
let token = await UsersService.createEmailConfirmToken(user, email, redirectURI);
let token = await UsersService.createEmailConfirmToken(
user,
email,
redirectURI
);
return MailerService.sendSimple({
template: 'email-confirm',
locals: {
token,
rootURL: ROOT_URL,
email
email,
},
subject: i18n.t('email.confirm.subject'),
to: email
to: email,
});
}
static async sendEmail(user, options) {
const localProfile = user.profiles.find((profile) => profile.provider === 'local');
const localProfile = user.profiles.find(
(profile) => profile.provider === 'local'
);
if (!localProfile) {
throw new Error('user does not have an email');
}
@@ -428,12 +484,15 @@ class UsersService {
static async changePassword(id, password) {
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
return UserModel.update({id}, {
$inc: {__v: 1},
$set: {
password: hashedPassword
return UserModel.update(
{id},
{
$inc: {__v: 1},
$set: {
password: hashedPassword,
},
}
});
);
}
/**
@@ -442,10 +501,15 @@ class UsersService {
* @return {Promise} Resolves with the users that were created
*/
static createLocalUsers(users) {
return Promise.all(users.map((user) => {
return UsersService
.createLocalUser(user.email, user.password, user.username);
}));
return Promise.all(
users.map((user) => {
return UsersService.createLocalUser(
user.email,
user.password,
user.username
);
})
);
}
/**
@@ -466,7 +530,6 @@ class UsersService {
}
if (checkAgainstWordlist) {
// check for profanity
let err = await Wordlist.usernameCheck(username);
if (err) {
@@ -501,7 +564,6 @@ class UsersService {
* @param {Function} done callback
*/
static async createLocalUser(email, password, username) {
if (!email) {
throw errors.ErrMissingEmail;
}
@@ -511,7 +573,7 @@ class UsersService {
await Promise.all([
UsersService.isValidUsername(username),
UsersService.isValidPassword(password)
UsersService.isValidPassword(password),
]);
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
@@ -523,17 +585,17 @@ class UsersService {
profiles: [
{
id: email,
provider: 'local'
}
provider: 'local',
},
],
status: {
username: {
status: 'SET',
history: {
status: 'SET'
}
}
}
status: 'SET',
},
},
},
});
try {
@@ -566,7 +628,7 @@ class UsersService {
/**
* Finds a user with the id.
* @param {String} id user id (uuid)
*/
*/
static findById(id) {
return UserModel.findOne({id});
}
@@ -577,13 +639,11 @@ class UsersService {
* @param {Object} token a jwt token used to sign in the user
*/
static async findOrCreateByIDToken(id, token) {
// Try to get the user.
let user = await UserModel.findOne({id});
// If the user was not found, try to look it up.
if (user === null) {
// If the user wasn't found, it will return null and the variable will be
// unchanged.
user = await lookupUserNotFound(token);
@@ -595,21 +655,24 @@ class UsersService {
/**
* Finds users in an array of ids.
* @param {Array} ids array of user identifiers (uuid)
*/
*/
static findByIdArray(ids) {
return UserModel.find({
id: {$in: ids}
id: {$in: ids},
});
}
/**
* Finds public user information by an array of ids.
* @param {Array} ids array of user identifiers (uuid)
*/
*/
static findPublicByIdArray(ids) {
return UserModel.find({
id: {$in: ids}
}, 'id username');
return UserModel.find(
{
id: {$in: ids},
},
'id username'
);
}
/**
@@ -618,7 +681,9 @@ class UsersService {
*/
static async createPasswordResetToken(email, loc) {
if (!email || typeof email !== 'string') {
throw new Error('email is required when creating a JWT for resetting passord');
throw new Error(
'email is required when creating a JWT for resetting passord'
);
}
email = email.toLowerCase();
@@ -628,7 +693,6 @@ class UsersService {
DomainList.urlCheck(loc),
]);
if (!user) {
// Since we don't want to reveal that the email does/doesn't exist
// just go ahead and resolve the Promise with null and check in the
// endpoint.
@@ -646,12 +710,12 @@ class UsersService {
email,
loc,
userId: user.id,
version: user.__v
version: user.__v,
};
return JWT_SECRET.sign(payload, {
expiresIn: '1d',
subject: PASSWORD_RESET_JWT_SUBJECT
subject: PASSWORD_RESET_JWT_SUBJECT,
});
}
@@ -678,7 +742,7 @@ class UsersService {
*/
static async verifyPasswordResetToken(token) {
const {userId, loc, version} = await UsersService.verifyToken(token, {
subject: PASSWORD_RESET_JWT_SUBJECT
subject: PASSWORD_RESET_JWT_SUBJECT,
});
const user = await UsersService.findById(userId);
@@ -708,17 +772,16 @@ class UsersService {
return UserModel.find({
$or: [
// Search by a prefix match on the username.
{
'lowercaseUsername': {
lowercaseUsername: {
$regex,
},
},
// Search by a prefix match on the email address.
{
'profiles': {
profiles: {
$elemMatch: {
id: {
$regex,
@@ -752,13 +815,16 @@ class UsersService {
* @return {Promise}
*/
static updateSettings(id, settings) {
return UserModel.update({
id
}, {
$set: {
settings
return UserModel.update(
{
id,
},
{
$set: {
settings,
},
}
});
);
}
/**
@@ -774,7 +840,7 @@ class UsersService {
item_type: 'users',
user_id,
action_type,
metadata
metadata,
});
}
@@ -787,7 +853,9 @@ class UsersService {
*/
static async createEmailConfirmToken(user, email, referer = ROOT_URL) {
if (!email || typeof email !== 'string') {
throw new Error('email is required when creating a JWT for resetting password');
throw new Error(
'email is required when creating a JWT for resetting password'
);
}
// Conform the email to lowercase.
@@ -796,22 +864,27 @@ class UsersService {
const tokenOptions = {
jwtid: uuid.v4(),
expiresIn: '1d',
subject: EMAIL_CONFIRM_JWT_SUBJECT
subject: EMAIL_CONFIRM_JWT_SUBJECT,
};
// Get the profile representing the local account.
let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local');
let profile = user.profiles.find(
(profile) => profile.id === email && profile.provider === 'local'
);
// Ensure that the user email hasn't already been verified.
if (profile && profile.metadata && profile.metadata.confirmed_at) {
throw new Error('email address already confirmed');
}
return JWT_SECRET.sign({
email,
referer,
userID: user.id
}, tokenOptions);
return JWT_SECRET.sign(
{
email,
referer,
userID: user.id,
},
tokenOptions
);
}
/**
@@ -823,7 +896,7 @@ class UsersService {
*/
static async verifyEmailConfirmation(token) {
let {userID, email, referer} = await UsersService.verifyToken(token, {
subject: EMAIL_CONFIRM_JWT_SUBJECT
subject: EMAIL_CONFIRM_JWT_SUBJECT,
});
await UsersService.confirmEmail(userID, email);
@@ -835,20 +908,22 @@ class UsersService {
* Marks the email on the user as confirmed.
*/
static confirmEmail(id, email) {
return UserModel
.update({
return UserModel.update(
{
id,
profiles: {
$elemMatch: {
id: email,
provider: 'local'
}
}
}, {
provider: 'local',
},
},
},
{
$set: {
'profiles.$.metadata.confirmed_at': new Date()
}
});
'profiles.$.metadata.confirmed_at': new Date(),
},
}
);
}
/**
@@ -866,13 +941,16 @@ class UsersService {
throw errors.ErrCannotIgnoreStaff;
}
return UserModel.update({id}, {
$addToSet: {
ignoresUsers: {
$each: usersToIgnore
}
return UserModel.update(
{id},
{
$addToSet: {
ignoresUsers: {
$each: usersToIgnore,
},
},
}
});
);
}
/**
@@ -881,24 +959,26 @@ class UsersService {
* @param {Array<String>} usersToStopIgnoring Array of user IDs to stop ignoring
*/
static async stopIgnoringUsers(id, usersToStopIgnoring) {
await UserModel.update({id}, {
$pullAll: {
ignoresUsers: usersToStopIgnoring
await UserModel.update(
{id},
{
$pullAll: {
ignoresUsers: usersToStopIgnoring,
},
}
});
);
}
}
module.exports = UsersService;
events.on(USERS_BAN_CHANGE, async (user, {status, message}) => {
// Check to see if the user was banned now and is currently banned.
if (user.banned && status && message && message.length > 0) {
await UsersService.sendEmail(user, {
template: 'plain',
locals: {
body: message
body: message,
},
subject: 'Your account has been banned',
});
@@ -906,9 +986,14 @@ events.on(USERS_BAN_CHANGE, async (user, {status, message}) => {
});
events.on(USERS_SUSPENSION_CHANGE, async (user, {until, message}) => {
// Check to see if the user was suspended now and is currently suspended.
if (user.suspended && until !== null && until > Date.now() && message && message.length > 0) {
if (
user.suspended &&
until !== null &&
until > Date.now() &&
message &&
message.length > 0
) {
await UsersService.sendEmail(user, {
template: 'plain',
locals: {
+1 -1
View File
@@ -11,7 +11,7 @@ module.exports = {
shutdown();
done();
},
waitForConditionTimeout: 5000,
waitForConditionTimeout: parseInt(process.env.WAIT_FOR_TIMEOUT) || 10000,
testData: {
admin: {
email: 'admin@test.com',
+8 -4
View File
@@ -57,6 +57,10 @@ module.exports = {
suspendUserDialog: '.talk-admin-suspend-user-dialog',
suspendUserConfirmButton: '.talk-admin-suspend-user-dialog-confirm',
supendUserSendButton: '.talk-admin-suspend-user-dialog-send',
usernameDialog: '.talk-reject-username-dialog',
usernameDialogButtons: '.talk-reject-username-dialog-buttons',
usernameDialogSuspend: '.talk-reject-username-dialog-button-k',
usernameDialogSuspensionMessage: '.talk-reject-username-dialog-suspension-message',
toast: '.toastify',
toastClose: '.toastify__close',
},
@@ -95,10 +99,6 @@ module.exports = {
flaggedUser:'.talk-admin-community-flagged-user',
flaggedUserApproveButton: '.talk-admin-flagged-user-approve-button',
flaggedUserRejectButton: '.talk-admin-flagged-user-reject-button',
usernameDialog: '.talk-reject-username-dialog',
usernameDialogButtons: '.talk-reject-username-dialog-buttons',
usernameDialogSuspend: '.talk-reject-username-dialog-button-k',
usernameDialogSuspensionMessage: '.talk-reject-username-dialog-suspension-message'
},
sections: {
people: {
@@ -143,6 +143,10 @@ module.exports = {
this.parent
.click('@drawerOverlay')
.waitForElementNotPresent('@drawerOverlay');
// Wait a bit to let animations terminate cleanly.
this.api.pause(200);
return this.parent;
},
}],
+5 -16
View File
@@ -62,24 +62,12 @@ module.exports = {
// Focusing on the Login PopUp
windowHandler.windowHandles((handles) => {
this.api.switchWindow(handles[1]);
});
const popup = this.api.page.popup().ready();
callback(popup);
const popup = this.api.page.popup().ready();
callback(popup);
// Give a tiny bit of time to let popup close.
this.api.pause(50);
if (this.api.capabilities.browserName === 'MicrosoftEdge') {
// More time for edge.
// https://www.browserstack.com/automate/builds/1ceccf4efb4683b7feb890f45a32b5922b40ed3f/sessions/7393dbfda8387e43b6d5851f359b0c07db414973
this.api.pause(1000);
}
// Focusing on the Embed Window
windowHandler.windowHandles((handles) => {
this.api.switchWindow(handles[0]);
// Focus on the Embed Window.
windowHandler.pop();
// For some reasons firefox does not automatically load auth after login.
// https://www.browserstack.com/automate/builds/37650cb4e66c6edce0ba0800a1c1b7e7f74bf991/sessions/7a4e9da69b0f9ecdf8b7fa9150639e47b1532cb0#automate_button
@@ -89,6 +77,7 @@ module.exports = {
this.parent.switchToIframe();
}
});
return this;
},
logout() {
+25 -9
View File
@@ -20,12 +20,10 @@ module.exports = {
adminPage.navigateAndLogin(admin);
},
'admin flags user\'s username as offensive': (client) => {
'admin flags users username as offensive': (client) => {
const embedStream = client.page.embedStream();
const comments = embedStream
.navigate()
.ready();
const comments = embedStream.navigate().ready();
comments
.waitForElementVisible('@firstComment')
@@ -63,15 +61,18 @@ module.exports = {
.click('@flaggedUserRejectButton');
},
'admin suspends the user': (client) => {
const adminPage = client.page.admin();
const community = client.page.admin().section.community;
community
adminPage
.waitForElementVisible('@usernameDialog')
.waitForElementVisible('@usernameDialogButtons')
.waitForElementVisible('@usernameDialogSuspend')
.click('@usernameDialogSuspend')
.click('@usernameDialogSuspend')
.waitForElementNotPresent('@flaggedUser');
.waitForElementVisible('@usernameDialogSuspensionMessage')
.click('@usernameDialogSuspend');
community.waitForElementNotPresent('@flaggedUser');
},
'admin logs out': (client) => {
client.page.admin().logout();
@@ -89,8 +90,15 @@ module.exports = {
const embedStream = client.page.embedStream();
const comments = embedStream.section.comments;
comments.waitForElementVisible('@restrictedMessageBox');
},
'user should not be able to comment': (client) => {
const embedStream = client.page.embedStream();
const comments = embedStream.section.comments;
comments
.waitForElementVisible('@restrictedMessageBox');
.waitForElementNotPresent('@commentBoxTextarea')
.waitForElementNotPresent('@commentBoxPostButton');
},
'user picks another username': (client) => {
const embedStream = client.page.embedStream();
@@ -103,5 +111,13 @@ module.exports = {
.waitForElementVisible('@changeUsernameSubmitButton')
.click('@changeUsernameSubmitButton')
.waitForElementNotPresent('@changeUsernameInput');
}
},
'user should be able to comment': (client) => {
const embedStream = client.page.embedStream();
const comments = embedStream.section.comments;
comments
.waitForElementVisible('@commentBoxTextarea')
.waitForElementVisible('@commentBoxPostButton');
},
};
+14
View File
@@ -25,8 +25,14 @@ class SortedWindowHandler {
*/
windowHandles(callback) {
this.client.windowHandles((result) => {
if (result.value.message) {
throw new Error(result.value.message);
}
this.handles = this.handles.filter((handle) => result.value.includes(handle));
const remaining = result.value.filter((handle) => !this.handles.includes(handle));
if (remaining.length === 1) {
this.handles.push(remaining[0]);
}
@@ -36,6 +42,14 @@ class SortedWindowHandler {
callback(this.handles);
});
}
/**
* Switch to main window handle and remove latest handle.
*/
pop() {
this.client.switchWindow(this.handles[0]);
this.handles.splice(-1, 1);
}
}
module.exports = SortedWindowHandler;
+352 -141
View File
File diff suppressed because it is too large Load Diff