mirror of
https://github.com/wassname/talk.git
synced 2026-07-09 12:19:33 +08:00
merge conficts
This commit is contained in:
@@ -13,6 +13,7 @@ client/coral-framework/graphql/introspection.json
|
||||
.idea/
|
||||
*.swp
|
||||
*.DS_STORE
|
||||
.prettierrc.json
|
||||
|
||||
coverage/
|
||||
test/e2e/reports/
|
||||
|
||||
@@ -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> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
{comment.editing && comment.editing.edited ? (
|
||||
<span>
|
||||
<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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
.dialog {
|
||||
}
|
||||
|
||||
:global(.backdrop) {
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background-color: black;
|
||||
opacity: 0.1;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 Project’s 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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
const i18n = require('../services/i18n');
|
||||
|
||||
module.exports = (req, res, next) => {
|
||||
res.locals.t = i18n.request(req);
|
||||
next();
|
||||
};
|
||||
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -11,7 +11,7 @@ module.exports = {
|
||||
shutdown();
|
||||
done();
|
||||
},
|
||||
waitForConditionTimeout: 5000,
|
||||
waitForConditionTimeout: parseInt(process.env.WAIT_FOR_TIMEOUT) || 10000,
|
||||
testData: {
|
||||
admin: {
|
||||
email: 'admin@test.com',
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}],
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user