Compare commits

...
142 Commits
Author SHA1 Message Date
Wyatt Johnson e2cb3fdd3d Merge pull request #1232 from coralproject/expiry-fix
Expiry Parsing Bug
2017-12-20 15:48:52 -07:00
Wyatt Johnson 2233d993c2 fixed expiry loading 2017-12-20 15:27:55 -07:00
Wyatt Johnson b2cc4bdb12 Merge pull request #1231 from coralproject/subscription-hooks
Subscription Connection Plugin Hooks
2017-12-20 10:07:21 -07:00
Wyatt Johnson 0a793938d6 Merge branch 'master' into subscription-hooks 2017-12-20 09:51:10 -07:00
Wyatt Johnson b46b392af4 added subscription hooks 2017-12-20 09:31:59 -07:00
Wyatt Johnson 998cb8d380 Merge pull request #1227 from coralproject/body-parser
Deprecate body-parser
2017-12-20 08:58:10 -07:00
Wyatt Johnson df0ab7c1dc removed body-parser in favor of express.json() 2017-12-19 15:11:11 -07:00
Kiwi 519464ce57 Merge pull request #1226 from coralproject/handle-empty-queue
Fix error when queue is empty
2017-12-19 20:58:34 +01:00
Chi Vinh Le f27de815e6 Minor syntax issue 2017-12-19 20:48:59 +01:00
Chi Vinh Le e8553c5035 Revert debug code :-D 2017-12-19 20:46:59 +01:00
Chi Vinh Le 349341400d Fix error when queue is empty 2017-12-19 20:39:41 +01:00
Kim Gardner 7b92e58ff3 Merge pull request #1225 from coralproject/portal-dialog
Use Portal to put dialogs at the end of the document
2017-12-19 13:48:30 -05:00
Chi Vinh Le f2f7742709 Adapt e2e 2017-12-19 19:40:16 +01:00
Chi Vinh Le 689fb4baf0 Remove z-index 2017-12-19 19:26:22 +01:00
Chi Vinh Le 650f98a080 Merge branch 'portal-dialog' of ssh://github.com/coralproject/talk into portal-dialog 2017-12-19 19:25:35 +01:00
Chi Vinh Le 88d3a53165 Make backdrop work 2017-12-19 19:24:39 +01:00
Kiwi 6e88976294 Merge branch 'master' into portal-dialog 2017-12-19 19:17:50 +01:00
Chi Vinh Le f25cc56ddb Use Portal to put dialogs to the end of the document 2017-12-19 19:15:45 +01:00
Kiwi 4134b2edb2 Merge pull request #1222 from coralproject/app-plugin
Added support for app plugin hook
2017-12-19 19:08:27 +01:00
Kiwi 5b886b6eea Merge branch 'master' into app-plugin 2017-12-19 18:57:31 +01:00
Kiwi 6c80de2981 Merge pull request #1224 from coralproject/e2e-stability
Increase e2e stability
2017-12-19 18:57:17 +01:00
Kiwi b9b3355317 Merge branch 'master' into e2e-stability 2017-12-19 18:49:35 +01:00
Chi Vinh Le 88e9faea00 Increase e2e stability 2017-12-19 18:48:44 +01:00
Kiwi b1c9fbedd8 Merge branch 'master' into app-plugin 2017-12-19 18:37:01 +01:00
Kiwi c9b7eb3494 Merge pull request #1223 from coralproject/fix-dialog
Fix dialog not rendering fully in firefox
2017-12-19 18:34:49 +01:00
Chi Vinh Le 66287f5b7d Fix dialog not rendering fully in firefox 2017-12-19 18:27:19 +01:00
Kiwi b61eeeba74 Merge branch 'master' into app-plugin 2017-12-19 18:02:43 +01:00
Kim Gardner 2179632c94 Merge pull request #1221 from coralproject/e2e-fixes
E2e improvements and fixes
2017-12-19 11:42:10 -05:00
Wyatt Johnson 4342161a70 Merge branch 'master' into app-plugin 2017-12-19 09:31:13 -07:00
Kim Gardner a48688ad35 Merge branch 'master' into e2e-fixes 2017-12-19 11:18:28 -05:00
Kim Gardner 1b4f783abd Merge pull request #1208 from coralproject/virtualized
Mod Queue improvements
2017-12-19 11:18:09 -05:00
Wyatt Johnson e2d8562868 added support for app plugin hook 2017-12-19 09:13:01 -07:00
Kim Gardner 91602b9d34 Merge branch 'master' into virtualized 2017-12-19 10:33:22 -05:00
Kim Gardner eba530eda0 Merge branch 'master' into e2e-fixes 2017-12-19 10:33:18 -05:00
Kim Gardner 8e1cfc3a29 Merge pull request #1219 from coralproject/bump-version-3-9-0
Version 3.9.0
2017-12-19 10:33:05 -05:00
Chi Vinh Le c78bb598e7 Merge branch 'e2e-fixes' of ssh://github.com/coralproject/talk into e2e-fixes 2017-12-19 16:27:20 +01:00
Chi Vinh Le f12abbf39e Default timeout to 10000 for ci 2017-12-19 16:26:55 +01:00
Kiwi 7dcb1dfbaa Merge branch 'master' into e2e-fixes 2017-12-19 16:16:59 +01:00
Chi Vinh Le 1fce2451c7 Remove some blank lines 2017-12-19 16:16:59 +01:00
Chi Vinh Le e4f1f5d08b Allow disabling e2e in ci 2017-12-19 16:14:59 +01:00
Chi Vinh Le d06ff8c93c Double default WaitForTimeout 2017-12-19 15:56:54 +01:00
Kim Gardner 9d9056ef86 Merge branch 'master' into bump-version-3-9-0 2017-12-19 08:28:01 -06:00
Kim Gardner 1b61ca8abf Merge branch 'master' into virtualized 2017-12-19 08:27:58 -06:00
Kim Gardner f75a743090 Merge pull request #1220 from coralproject/schema-level-plugin-support
Schema + Connector Plugin Support
2017-12-19 08:27:32 -06:00
Chi Vinh Le 82e617b19b Enable firefox and edge 2017-12-19 14:48:58 +01:00
Chi Vinh Le 7a4b87f743 Fix popup issues 2017-12-19 14:42:54 +01:00
Chi Vinh Le 71f0887a82 Make browsers configurable through ENV E2E_BROWSERS 2017-12-19 14:42:29 +01:00
Chi Vinh Le 4116e206a3 Configurable timeout 2017-12-19 14:41:51 +01:00
Chi Vinh Le e78f17460f Fix wrong spec 2017-12-19 14:27:34 +01:00
Kim Gardner 70bfd91359 Merge branch 'master' into virtualized 2017-12-18 17:37:04 -06:00
Kim Gardner b3432a5e0d Merge branch 'master' into bump-version-3-9-0 2017-12-18 17:36:56 -06:00
Wyatt Johnson 382cb42dcf Merge branch 'master' into schema-level-plugin-support 2017-12-18 16:27:38 -07:00
Wyatt Johnson dbd106d523 added support for plugging into schema level resolve 2017-12-18 16:24:28 -07:00
Kim Gardner e8f94d4d4a Merge pull request #1209 from coralproject/i18n-serverside-plugins
i18n + Email Support
2017-12-18 17:03:47 -06:00
Kim Gardner 3ea141d4b0 Merge branch 'master' into i18n-serverside-plugins 2017-12-18 16:44:23 -06:00
Kim Gardner f783c8423d Merge pull request #1211 from coralproject/premod-admin-comments
Fix new admin comments not showing up in premod
2017-12-18 16:44:15 -06:00
Kim Gardner 9d4a9861f2 Merge branch 'master' into premod-admin-comments 2017-12-18 16:34:13 -06:00
Kim Gardner 6d69286df6 Merge pull request #1214 from coralproject/confirmation-featured
Confirmation Dialog for Featuring Comments on the Admin Side
2017-12-18 16:34:01 -06:00
Kim Gardner 3121ffb55a Version 3.9.0 2017-12-18 17:31:25 -05:00
Kim Gardner c0af51fbc0 Merge branch 'master' into confirmation-featured 2017-12-18 16:27:00 -06:00
Kim Gardner ad85b0fcc8 Merge pull request #1216 from coralproject/change-permalink-share
Update permalink copy to "Share" on stream
2017-12-18 16:26:43 -06:00
Kim Gardner 5ad73b1c14 Merge branch 'master' into change-permalink-share 2017-12-18 16:17:31 -06:00
Kim Gardner bf83f462e0 Merge pull request #1217 from coralproject/fix-login-profile
Fix sign in on profile tab
2017-12-18 16:17:19 -06:00
Kim Gardner 8dd79a19c3 Merge branch 'master' into fix-login-profile 2017-12-18 16:09:37 -06:00
Kim Gardner a8368eb78d Merge pull request #1218 from coralproject/ci-fix
Disabling Firefox/Edge on E2E until fix pass
2017-12-18 13:58:02 -06:00
Wyatt Johnson e88bac6e0f Merge branch 'master' into ci-fix 2017-12-18 12:45:40 -07:00
Kim Gardner 281d1f65f1 Merge branch 'master' into i18n-serverside-plugins 2017-12-18 13:44:20 -06:00
Kim Gardner 2704d71b09 Merge branch 'master' into fix-login-profile 2017-12-18 13:43:45 -06:00
Wyatt Johnson 583ab0ff74 disabling firefox,edge 2017-12-18 12:42:58 -07:00
Kim Gardner ad5c0444f7 Merge branch 'master' into virtualized 2017-12-18 12:43:16 -06:00
Belén Curcio 2a74e75506 Merge branch 'master' into confirmation-featured 2017-12-18 15:39:55 -03:00
Wyatt Johnson fb7ad76b8b Merge pull request #1215 from coralproject/ci-fix
*Fixes for sorted window handler
2017-12-18 11:36:47 -07:00
Kiwi eddb471366 Merge branch 'master' into virtualized 2017-12-18 19:29:35 +01:00
okbel 35c88ffab4 spreading the rest first 2017-12-18 15:21:02 -03:00
okbel 1cdef8f864 bounded 2017-12-18 15:20:06 -03:00
Kim Gardner 82fd56dc67 Update permalink copy to Share on stream 2017-12-18 13:09:39 -05:00
Chi Vinh Le 0e01d40aa7 Fix sign in on profile tab 2017-12-18 19:01:43 +01:00
Wyatt Johnson f7d1543aa3 fixes for sorted window handler 2017-12-18 10:24:11 -07:00
Belén Curcio d2ebc81e25 Merge branch 'master' into confirmation-featured 2017-12-18 13:52:01 -03:00
Belen Curcio 278e1cec1d done 2017-12-18 13:42:59 -03:00
Kim Gardner 31436e627d Merge branch 'master' into i18n-serverside-plugins 2017-12-18 10:39:27 -06:00
Kim Gardner 4e50865927 Merge branch 'master' into premod-admin-comments 2017-12-18 10:39:06 -06:00
Belen Curcio 6ad218782e styling 2017-12-18 13:39:02 -03:00
Belen Curcio 8f1f875d76 less data to the store 2017-12-18 13:36:30 -03:00
Belen Curcio 9fb8e01cd2 working, still wip 2017-12-18 13:34:48 -03:00
Kim Gardner 950a35310b Merge pull request #1212 from coralproject/user-detail-load-more-fix
Fix load more in UserDetail issue
2017-12-18 10:23:50 -06:00
Kiwi 12884cce01 Merge branch 'master' into i18n-serverside-plugins 2017-12-18 17:12:54 +01:00
Kiwi f8d153cf95 Merge branch 'master' into virtualized 2017-12-18 17:12:49 +01:00
Kiwi c871f1c797 Merge branch 'master' into premod-admin-comments 2017-12-18 17:12:41 +01:00
Kiwi 9af7ae0357 Merge branch 'master' into user-detail-load-more-fix 2017-12-18 17:12:36 +01:00
Wyatt Johnson 073eea2847 Merge pull request #1213 from coralproject/version-bump
Version Bump
2017-12-18 09:11:52 -07:00
Kiwi 9cb6ff3847 Merge branch 'master' into virtualized 2017-12-18 16:59:31 +01:00
Wyatt Johnson c699e044fd Update package.json 2017-12-18 08:55:49 -07:00
Kiwi 7d3ec7da1e Merge branch 'master' into premod-admin-comments 2017-12-18 16:55:38 +01:00
Belen Curcio 5d288da69d Merge branch 'master' of github.com:coralproject/talk into confirmation-featured 2017-12-18 12:55:31 -03:00
Kiwi 4ef7abc132 Merge branch 'master' into user-detail-load-more-fix 2017-12-18 16:55:31 +01:00
Wyatt Johnson 9e486f3a9d Merge pull request #1210 from coralproject/redis-optim
Redis Optimization Pass
2017-12-18 08:55:18 -07:00
Chi Vinh Le fbcd390220 Fix load more issue 2017-12-18 16:52:57 +01:00
okbel 338c23dc3d unnecesary class 2017-12-18 12:46:24 -03:00
okbel 4d6f38a3d9 wip 2017-12-18 12:43:46 -03:00
Chi Vinh Le 5b8f335b2a Fix new admin comments not showing up in premod 2017-12-18 16:41:05 +01:00
Wyatt Johnson f0330ed21c fixed version paths 2017-12-18 08:35:17 -07:00
okbel 02da749c65 Adding containers and components 2017-12-18 12:20:20 -03:00
okbel 5a1393c039 Adding translations 2017-12-18 12:20:06 -03:00
okbel d7a5e613fa Adding reducer 2017-12-18 12:19:46 -03:00
Wyatt Johnson 75a944eba8 upgrades, fixes to comment count logic 2017-12-18 08:02:43 -07:00
Chi Vinh Le 9a0fb95d06 Pagination fixes 2017-12-18 15:03:54 +01:00
Wyatt Johnson 6850dd1a53 redis optim
- removes call for comment count when we already have
  totalCommentCount
- removes job processor cleaner on non-processing nodes
- fixed some template issues
2017-12-15 15:49:56 -06:00
Wyatt Johnson ecb01224fd Merge branch 'master' into i18n-serverside-plugins 2017-12-15 10:40:08 -06:00
Wyatt Johnson fdd5cbb0d7 moved email contents to translation files 2017-12-14 16:56:24 -06:00
Wyatt Johnson eb9827a3ae more fixes for i18n 2017-12-14 16:48:03 -06:00
Wyatt Johnson d207b240f2 added support for changing the subject prefi 2017-12-14 16:28:08 -06:00
Wyatt Johnson d9654b52cf some fixes 2017-12-14 16:09:41 -06:00
Wyatt Johnson d3965924aa fixed template 2017-12-14 15:51:53 -06:00
Wyatt Johnson 36d535546c plugins serverside can now provide translations 2017-12-14 15:03:35 -06:00
Chi Vinh Le 19568afde5 Reduce view more movements 2017-12-14 21:08:13 +01:00
Chi Vinh Le 2b312dbf8e Next try 2017-12-14 20:41:05 +01:00
Chi Vinh Le 5446abe3a9 Fix bug with featured comments 2017-12-14 20:37:51 +01:00
Chi Vinh Le c66c100dcb Cache callbacks to preserve immutability 2017-12-14 20:27:19 +01:00
Chi Vinh Le d4051f6828 Fix view more styling 2017-12-14 20:08:09 +01:00
Chi Vinh Le 33781c3d16 Fix view new comments 2017-12-14 20:01:33 +01:00
Chi Vinh Le defc4049cb Sort correctly + refactors and comments 2017-12-14 19:47:42 +01:00
Chi Vinh Le 243d0c1493 Limit to 100 comments 2017-12-14 18:33:49 +01:00
Kiwi a448c69a2a Merge branch 'master' into virtualized 2017-12-14 18:32:28 +01:00
Chi Vinh Le 0b774402cf Introduce dangling comments and queue cleanup 2017-12-14 18:19:00 +01:00
Chi Vinh Le 0bda44e97f Fix cursor bug 2017-12-14 16:51:50 +01:00
Chi Vinh Le 60f6c63c8a Restrict notifications to comments which are rendered 2017-12-14 16:29:31 +01:00
Chi Vinh Le 62c2d551f8 Append comments only 2017-12-14 15:57:44 +01:00
Chi Vinh Le f32bb5ce8f Small adjustments 2017-12-14 13:55:08 +01:00
Chi Vinh Le 5e6d22f1af Handle removing comments from queue 2017-12-14 13:38:01 +01:00
Chi Vinh Le 34289e3340 Refactor view 2017-12-14 12:35:26 +01:00
Wyatt Johnson 98ef60a48e Merge pull request #1205 from stephendonner/patch-1
s/setup/setup
2017-12-13 12:34:30 -06:00
Wyatt Johnson 8323344d7d Merge branch 'master' into patch-1 2017-12-13 12:26:24 -06:00
Wyatt Johnson fbecc363cf Merge pull request #1206 from coralproject/feature/infer-asset-url
Fix logic to generate asset_url even if the asset_id is provided
2017-12-13 12:25:27 -06:00
Chi Vinh Le 2779734ec0 Workaround 2017-12-13 15:25:47 +01:00
Chi Vinh Le 5548deb6b5 Auto Load More and other bug fixes 2017-12-13 15:06:01 +01:00
Jeff Nelson df925f5a1a Fix logic to generate asset_url even if the asset_id is provided so
an error is avoided
2017-12-13 07:38:10 -06:00
Stephen Donner 30073b88f1 s/setup/setup 2017-12-13 03:49:35 -06:00
Chi Vinh Le 51bd109f13 Fix some bugs 2017-12-12 17:28:38 +01:00
Chi Vinh Le b12d5f6bd7 Reimplement keyboard navigation 2017-12-12 17:19:11 +01:00
Chi Vinh Le f32e4523e8 Make view more coments work with virtualized 2017-12-12 12:53:54 +01:00
Chi Vinh Le ed959ca07a Integrate react-virtualized 2017-12-11 20:21:27 +01:00
71 changed files with 1494 additions and 689 deletions
+21
View File
@@ -1,8 +1,10 @@
const express = require('express');
const morgan = require('morgan');
const path = require('path');
const uuid = require('uuid');
const merge = require('lodash/merge');
const helmet = require('helmet');
const plugins = require('./services/plugins');
const compression = require('compression');
const {HELMET_CONFIGURATION} = require('./config');
const {MOUNT_PATH} = require('./url');
@@ -11,6 +13,25 @@ const debug = require('debug')('talk:app');
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';
@@ -128,7 +128,7 @@ class UserDetailContainer extends React.Component {
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;
}
@@ -20,6 +20,16 @@ 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();
}
};
showSuspendUserDialog = () => {
const {comment, showSuspendUserDialog} = this.props;
return showSuspendUserDialog({
@@ -55,6 +65,12 @@ class Comment extends React.Component {
: this.props.rejectComment({commentId: this.props.comment.id})
);
componentDidUpdate(prev) {
if (!prev.selected && this.props.selected) {
this.ref.focus();
}
}
render() {
const {
comment,
@@ -65,6 +81,8 @@ class Comment extends React.Component {
root: {settings},
currentUserId,
currentAsset,
clearHeightCache,
dangling,
} = this.props;
const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
@@ -73,8 +91,11 @@ 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}>
@@ -114,6 +135,7 @@ class Comment extends React.Component {
<Slot
fill="adminCommentInfoBar"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
</div>
@@ -145,6 +167,7 @@ class Comment extends React.Component {
<Slot
fill="adminCommentContent"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
<div className={styles.sideActions}>
@@ -166,6 +189,7 @@ class Comment extends React.Component {
<Slot
fill="adminSideActions"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
</div>
@@ -176,6 +200,7 @@ class Comment extends React.Component {
data={data}
root={root}
comment={comment}
clearHeightCache={clearHeightCache}
/>
</li>
);
@@ -185,12 +210,16 @@ 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,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
currentUserId: PropTypes.string.isRequired,
clearHeightCache: PropTypes.func,
comment: PropTypes.shape({
id: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
@@ -12,15 +12,10 @@ 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,8 +25,6 @@ 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()
@@ -74,8 +67,7 @@ class Moderation extends Component {
}
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){
@@ -96,74 +88,16 @@ class Moderation extends Component {
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;
this.setState({isLoadingMore: false});
throw e;
}
}
@@ -176,56 +110,12 @@ 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}`));
}
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;
const {asset} = root;
@@ -266,17 +156,22 @@ 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}
selectedCommentId={moderation.selectedCommentId}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
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}
@@ -307,12 +202,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,
showBanUserDialog: 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,29 @@ 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 +43,306 @@ 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;
}
let idCursors = this.state.idCursors;
// Comments have been removed.
if (
prevComments && nextComments &&
nextComments.length < prevComments.length
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));
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));
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}
showBanUserDialog={this.props.showBanUserDialog}
showSuspendUserDialog={this.props.showSuspendUserDialog}
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 () {
const {
comments,
selectedCommentId,
commentCount,
singleView,
viewUserDetail,
activeTab,
...props
} = this.props;
@@ -159,54 +377,35 @@ 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}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
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>
);
}
@@ -216,14 +415,19 @@ ModerationQueue.propTypes = {
viewUserDetail: PropTypes.func.isRequired,
currentAsset: PropTypes.object,
showBanUserDialog: PropTypes.func.isRequired,
selectCommentId: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
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,6 +8,7 @@
overflow: visible;
height: 144px;
min-height: auto;
margin-top: 16px;
z-index: 10;
@media (--tablet) {
@@ -11,7 +11,7 @@ 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 {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
@@ -23,7 +23,8 @@ import {
toggleStorySearch,
setSortOrder,
storySearchChange,
clearState
clearState,
selectCommentId,
} from 'actions/moderation';
import withQueueConfig from '../hoc/withQueueConfig';
import {notify} from 'coral-framework/actions/notification';
@@ -168,6 +169,14 @@ class ModerationContainer extends Component {
}
}
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'});
}
@@ -176,9 +185,13 @@ class ModerationContainer extends Component {
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,
@@ -194,7 +207,6 @@ class ModerationContainer extends Component {
[tab]: {
nodes: {$push: comments.nodes},
hasNextPage: {$set: comments.hasNextPage},
startCursor: {$set: comments.startCursor},
endCursor: {$set: comments.endCursor},
},
});
@@ -246,6 +258,9 @@ class ModerationContainer extends Component {
activeTab={this.activeTab}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
selectedCommentId={this.props.selectedCommentId}
commentBelongToQueue={this.commentBelongToQueue}
cleanUpQueue={this.cleanUpQueue}
/>;
}
}
@@ -361,7 +376,8 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
${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
}
@@ -386,6 +402,9 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
organizationName
moderation
}
me {
id
}
...${getDefinitionName(Comment.fragments.root)}
}
${Comment.fragments.root}
@@ -423,6 +442,7 @@ const mapDispatchToProps = (dispatch) => ({
storySearchChange,
clearState,
notify,
selectCommentId,
}, dispatch),
});
@@ -16,16 +16,21 @@ function queueHasComment(root, queue, id) {
return root[queue].nodes.find((c) => c.id === id);
}
function removeCommentFromQueue(root, queue, id) {
function removeCommentFromQueue(root, queue, id, dangling = false) {
if (!queueHasComment(root, queue, id)) {
return root;
}
return update(root, {
const changes = {
[`${queue}Count`]: {$set: root[`${queue}Count`] - 1},
[queue]: {
};
if (!dangling) {
changes[queue] = {
nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)},
},
});
};
}
return update(root, changes);
}
function shouldCommentBeAdded(root, queue, comment, sortOrder) {
@@ -40,26 +45,46 @@ function shouldCommentBeAdded(root, queue, comment, sortOrder) {
: new Date(comment.created_at) >= cursor;
}
function addCommentToQueue(root, queue, comment, sortOrder) {
function addCommentToQueue(root, queue, comment, sortOrder, cleanup) {
if (queueHasComment(root, queue, comment.id)) {
return root;
}
const sortAlgo = sortOrder === 'ASC' ? ascending : descending;
const changes = {
[`${queue}Count`]: {$set: root[`${queue}Count`] + 1},
};
if (shouldCommentBeAdded(root, queue, comment, sortOrder)) {
const nodes = root[queue].nodes.concat(comment).sort(sortAlgo);
changes[queue] = {
nodes: {$set: nodes},
startCursor: {$set: nodes[0].created_at},
endCursor: {$set: nodes[nodes.length - 1].created_at},
};
if (!shouldCommentBeAdded(root, queue, comment, sortOrder)) {
return update(root, changes);
}
return update(root, changes);
const cursor = new Date(root[queue].startCursor);
const date = new Date(comment.created_at);
let append = sortOrder === 'ASC'
? date >= cursor
: date <= cursor;
const nodes = append
? root[queue].nodes.concat(comment)
: [comment].concat(...root[queue].nodes);
changes[queue] = {
nodes: {$set: nodes},
};
const next = update(root, changes);
if (!cleanup) {
return next;
}
return cleanUpQueue(next, queue, sortOrder);
}
function sortComments(nodes, sortOrder) {
const sortAlgo = sortOrder === 'ASC' ? ascending : descending;
return nodes.sort(sortAlgo);
}
/**
@@ -68,24 +93,92 @@ function addCommentToQueue(root, queue, comment, sortOrder) {
function getCommentQueues(comment, queueConfig) {
const queues = [];
Object.keys(queueConfig).forEach((key) => {
const {action_type, statuses, tags} = queueConfig[key];
let addToQueues = true;
if (statuses && statuses.indexOf(comment.status) === -1) {
addToQueues = false;
}
if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) {
addToQueues = false;
}
if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) {
addToQueues = false;
}
if (addToQueues) {
if (commentBelongToQueue(key, comment, queueConfig)) {
queues.push(key);
}
});
return queues;
}
/**
* Return whether or not the comment belongs to the queue.
*/
export function commentBelongToQueue(queue, comment, queueConfig) {
const {action_type, statuses, tags} = queueConfig[queue];
let belong = true;
if (statuses && statuses.indexOf(comment.status) === -1) {
belong = false;
}
if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) {
belong = false;
}
if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) {
belong = false;
}
return belong;
}
function isVisible(id) {
return !!document.getElementById(`comment_${id}`);
}
function isEndOfListVisible(root, queue) {
return root[queue].nodes.length === 0 || !!document.getElementById('end-of-comment-list');
}
function applyCommentChanges(root, comment, queueConfig) {
const queues = Object.keys(queueConfig);
for (let i = 0; i < queues.length; i++) {
const queue = queues[i];
const index = root[queue].nodes.findIndex(({id}) => id === comment.id);
if (index > -1) {
return update(root, {
[queue]: {
nodes: {
[index]: {$merge: comment},
},
},
});
}
}
return root;
}
/**
* Remove dangling comments, sort and resize queues.
* If queueConfig is omitted, dangling comments are not removed.
*/
export function cleanUpQueue(root, queue, sortOrder, queueConfig) {
let nodes = root[queue].nodes;
let hasNextPage = root[queue].hasNextPage;
if (!nodes.length) {
return root;
}
if (queueConfig) {
nodes = root[queue].nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig));
}
nodes = sortComments(
nodes,
sortOrder,
);
if (nodes.length > 100) {
nodes = nodes.slice(0, 100);
hasNextPage = true;
}
return update(root, {
[queue]: {
nodes: {$set: nodes},
endCursor: {$set: nodes[nodes.length - 1].created_at},
hasNextPage: {$set: hasNextPage},
},
});
}
/**
* Assimilate comment changes into current store.
* @param {Object} root current state of the store
@@ -113,25 +206,27 @@ export function handleCommentChange(root, comment, sortOrder, notify, queueConfi
Object.keys(queueConfig).forEach((queue) => {
if (nextQueues.indexOf(queue) >= 0) {
if (!queueHasComment(next, queue, comment.id)) {
next = addCommentToQueue(next, queue, comment, sortOrder);
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sortOrder)) {
next = addCommentToQueue(next, queue, comment, sortOrder, activeQueue !== queue);
if (notify && activeQueue === queue && isEndOfListVisible(root, queue)) {
showNotificationOnce();
}
}
} else if(queueHasComment(next, queue, comment.id)){
next = removeCommentFromQueue(next, queue, comment.id);
if (notify && activeQueue === queue) {
const dangling = activeQueue === queue && comment.status_history[comment.status_history.length - 1].assigned_by.id !== root.me.id;
next = removeCommentFromQueue(next, queue, comment.id, dangling);
if (notify && isVisible(comment.id)) {
showNotificationOnce();
}
}
if (
notify
&& queueHasComment(next, queue, comment.id)
&& activeQueue === queue
) {
if (notify && isVisible(comment.id)) {
showNotificationOnce();
}
// We need to apply every comment change, because we use
// batched subscription handler which bypasses apollo that would
// have done that for us.
next = applyCommentChanges(next, comment, queueConfig);
});
return next;
}
@@ -193,7 +193,12 @@ export default {
},
updateQueries: {
CoralEmbedStream_Embed: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
if (prev.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED' || comment.status === 'SYSTEM_WITHHELD') {
if (
prev.me.roles.indexOf('ADMIN') === -1 && prev.asset.settings.moderation === 'PRE' ||
comment.status === 'PREMOD' ||
comment.status === 'REJECTED' ||
comment.status === 'SYSTEM_WITHHELD'
) {
return prev;
}
return insertCommentIntoEmbedQuery(prev, comment);
+2 -16
View File
@@ -38,7 +38,7 @@ function applyToCommentsOrigin(root, callback) {
function findAndInsertComment(parent, comment) {
const isAsset = parent.__typename === 'Asset';
const [connectionField, countField, action] = isAsset
? ['comments', 'commentCount', '$unshift']
? ['comments', 'totalCommentCount', '$unshift']
: ['replies', 'replyCount', '$push'];
if (
@@ -67,19 +67,12 @@ function findAndInsertComment(parent, comment) {
}
export function insertCommentIntoEmbedQuery(root, comment) {
// Increase total comment count by one.
root = update(root, {
asset: {
totalCommentCount: {$apply: (c) => c + 1},
},
});
return applyToCommentsOrigin(root, (origin) => findAndInsertComment(origin, comment));
}
function findAndRemoveComment(parent, id) {
const [connectionField, countField] = parent.__typename === 'Asset'
? ['comments', 'commentCount']
? ['comments', 'totalCommentCount']
: ['replies', 'replyCount'];
const connection = parent[connectionField];
@@ -104,13 +97,6 @@ function findAndRemoveComment(parent, id) {
}
export function removeCommentFromEmbedQuery(root, id) {
// Decrease total comment by one.
root = update(root, {
asset: {
totalCommentCount: {$apply: (c) => c - 1},
},
});
return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id));
}
@@ -285,6 +285,7 @@ const fragments = {
ignoredUsers {
id
}
roles
}
settings {
organizationName
@@ -324,7 +325,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 {
@@ -345,7 +345,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,
+2 -3
View File
@@ -70,10 +70,9 @@ export class Talk {
// Extract the asset url.
if (opts.asset_url) {
query.asset_url = opts.asset_url;
} else if (!opts.asset_id) {
} else {
// The asset url was not provided and the asset id was also not provided,
// we need to infer the asset url from details on the page.
// The asset url was not provided so we need to infer the asset url from // details on the page.
try {
query.asset_url = document.querySelector('link[rel="canonical"]').href;
+20 -7
View File
@@ -49,8 +49,9 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
memoized = null;
resolvedDocument = null;
lastNetworkStatus = null;
data = null;
name = '';
apolloData = null;
data = null;
// Pending subscription data.
subscriptionQueue = [];
@@ -83,6 +84,9 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
// Handle any pending susbcription data in the subscription queue at max once every second.
// Updates are batched in written into apollo in one go.
processSubscriptionQueue = throttle(() => {
if (!this.subscriptionQueue.length) {
return;
}
const variables = typeof this.wrappedOptions === 'function'
? this.wrappedOptions(this.props).variables
: this.wrappedOptions.variables;
@@ -151,6 +155,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
};
nextData(data) {
this.apolloData = data;
this.emitWhenNeeded(data);
// If data was previously set, we update it in a immutable way.
@@ -181,16 +186,24 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
variables: data.variables,
networkStatus: data.networkStatus,
loading: data.loading,
startPolling: data.startPolling,
stopPolling: data.stopPolling,
refetch: data.refetch,
updateQuery: data.updateQuery,
subscribeToMoreThrottled: this.subscribeToMoreThrottled,
startPolling: (...args) => {
return this.apolloData.startPolling(...args);
},
stopPolling: (...args) => {
return this.apolloData.stopPolling(...args);
},
updateQuery: (...args) => {
return this.apolloData.updateQuery(...args);
},
refetch: (...args) => {
return this.apolloData.refetch(...args);
},
subscribeToMore: (stmArgs) => {
const resolvedDocument = this.resolveDocument(stmArgs.document);
// Resolve document fragments before passing it to `apollo-client`.
return data.subscribeToMore({
return this.apolloData.subscribeToMore({
...stmArgs,
document: resolvedDocument,
onError: (err) => {
@@ -209,7 +222,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
{variables: lmArgs.variables});
// Resolve document fragments before passing it to `apollo-client`.
return data.fetchMore({
return this.apolloData.fetchMore({
...lmArgs,
query: resolvedDocument,
})
@@ -61,7 +61,7 @@ class ProfileContainer extends Component {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
}
if (loading) {
if (loading || !me) {
return <Spinner />;
}
+13
View File
@@ -0,0 +1,13 @@
.dialog {
}
:global(.backdrop) {
left: 0;
top: 0;
width: 100%;
height: 100%;
position: absolute;
background-color: black;
opacity: 0.1;
}
+11 -7
View File
@@ -2,6 +2,8 @@ import React, {Component} from 'react';
import PropTypes from 'prop-types';
import dialogPolyfill from 'dialog-polyfill';
import 'dialog-polyfill/dialog-polyfill.css';
import styles from './Dialog.css';
import {Portal} from 'react-portal';
export default class Dialog extends Component {
static propTypes = {
@@ -52,13 +54,15 @@ export default class Dialog extends Component {
const {children, className = '', onClose, onCancel, open, ...rest} = this.props; // eslint-disable-line
return (
<dialog
ref={(el) => { this.dialog = el; }}
className={`mdl-dialog ${className}`}
{...rest}
>
{children}
</dialog>
<Portal>
<dialog
ref={(el) => { this.dialog = el; }}
className={`mdl-dialog ${className} ${styles.dialog}`}
{...rest}
>
{children}
</dialog>
</Portal>
);
}
}
+11
View File
@@ -20,10 +20,21 @@ const CONFIG = {
// WEBPACK indicates when webpack is currently building.
WEBPACK: process.env.WEBPACK === 'TRUE',
// 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
//------------------------------------------------------------------------------
+2 -2
View File
@@ -26,7 +26,7 @@ to persist data. The following versions are supported:
An optional dependency for Talk is
[Docker](https://www.docker.com/community-edition#/download){:target="_blank"}.
It is used during [development](#development) to setup the database and can be
It is used during [development](#development) to set up the database and can be
used to [install via Docker](#installation-from-docker). We have tested Talk
and this documentation with versions {{ site.versions.docker }}.
@@ -182,4 +182,4 @@ machine.
At this point you've successfully installed, configured, and ran your very own
instance of Talk! Continue through this documentation on this site to learn more
on how to configure, develop with, and contribute to Talk!
on how to configure, develop with, and contribute to Talk!
+7 -1
View File
@@ -460,4 +460,10 @@ Could be read as:
## TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF
When `TRUE`, staff members will have their accounts and comments moderated the
same as any other user in the system. (Default `FALSE`)
same as any other user in the system. (Default `FALSE`)
## TALK_EMAIL_SUBJECT_PREFIX
The prefix for the subject of emails sent. An email with the specified subject
of `Email Confirmation` would then be sent as `[Talk] Email Confirmation`.
(Default `[Talk]`)
+9 -1
View File
@@ -1,3 +1,6 @@
const debug = require('debug')('talk:graph:connectors');
const merge = require('lodash/merge');
// Errors.
const errors = require('../errors');
@@ -76,4 +79,9 @@ const connectors = {
},
};
module.exports = connectors;
module.exports = Plugins.get('server', 'connectors').reduce((defaultConnectors, {plugin, connectors: pluginConnectors}) => {
debug(`adding plugin '${plugin.name}'`);
// Merge in the plugin connectors.
return merge(defaultConnectors, pluginConnectors);
}, connectors);
+10 -6
View File
@@ -42,14 +42,15 @@ const decorateContextPlugins = (context, contextPlugins) => {
* Stores the request context.
*/
class Context {
constructor({user = null}) {
constructor(parent) {
// Generate a new context id for the request.
this.id = uuid.v4();
// Generate a new context id for the request if the parent doesn't provide
// one.
this.id = parent.id || uuid.v4();
// Load the current logged in user to `user`, otherwise this'll be null.
if (user) {
this.user = user;
// Load the current logged in user to `user`, otherwise this will be null.
if (parent.user) {
this.user = parent.user;
}
// Attach the connectors.
@@ -66,6 +67,9 @@ class Context {
// Bind the publish/subscribe to the context.
this.pubsub = pubsub.getClient();
// Bind the parent context.
this.parent = parent;
}
}
+3 -1
View File
@@ -72,8 +72,10 @@ class SharedCacheDataLoader extends DataLoader {
constructor(prefix, expiry, batchLoadFn, options) {
super(SharedCacheDataLoader.batchLoadFn(prefix, expiry, batchLoadFn), options);
// Expiry is provided as a number in ms, we're using commands optimized for
// seconds, so convert this to seconds.
this._expiry = Math.floor(expiry / 1000);
this._prefix = prefix;
this._expiry = expiry;
this._keyFunc = SharedCacheDataLoader.keyFunc(this._prefix);
}
+1 -1
View File
@@ -178,7 +178,7 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu
// just added a new comment, hence the counts should be updated. We should
// perform these increments in the event that we do have a new comment that
// is approved or without a comment.
if (status === 'NONE' || status === 'APPROVED') {
if (status === 'NONE' || status === 'ACCEPTED') {
if (parent_id === null) {
Comments.parentCountByAssetID.incr(asset_id);
}
+12 -1
View File
@@ -1,4 +1,8 @@
const {makeExecutableSchema} = require('graphql-tools');
const {
makeExecutableSchema,
addSchemaLevelResolveFunction,
} = require('graphql-tools');
const debug = require('debug')('talk:graph:schema');
const {decorateWithHooks} = require('./hooks');
const {decorateWithErrorHandler} = require('./errorHandler');
@@ -14,4 +18,11 @@ decorateWithHooks(schema, plugins.get('server', 'hooks'));
// Handle errors like masking in production and mutation errors.
decorateWithErrorHandler(schema);
// For each schemaLevelResolveFunction, add it to the schema.
plugins.get('server', 'schemaLevelResolveFunction').forEach(({plugin, schemaLevelResolveFunction}) => {
debug(`added schemaLevelResolveFunction from plugin '${plugin.name}'`);
addSchemaLevelResolveFunction(schema, schemaLevelResolveFunction);
});
module.exports = schema;
+34 -3
View File
@@ -5,6 +5,7 @@ const debug = require('debug')('talk:graph:subscriptions');
const pubsub = require('../services/pubsub');
const schema = require('./schema');
const Context = require('./context');
const plugins = require('../services/plugins');
const {deserializeUser} = require('../services/subscriptions');
const setupFunctions = require('./setupFunctions');
@@ -16,16 +17,42 @@ const {
const {BASE_PATH} = require('../url');
const onConnect = ({token}, connection) => {
// Collect all the plugin hooks that should be executed onConnect and
// onDisconnect.
const hooks = plugins.get('server', 'websockets')
.map(({plugin, websockets}) => {
debug(`added websocket hooks ${Object.keys(websockets)} from plugin '${plugin.name}'`);
return websockets;
})
.reduce((hooks, {onConnect = null, onDisconnect = null}) => {
if (onConnect) {
hooks.onConnect.push(onConnect);
}
if (onDisconnect) {
hooks.onDisconnect.push(onDisconnect);
}
return hooks;
}, {
onConnect: [],
onDisconnect: [],
});
const onConnect = async (connectionParams, connection) => {
// Attach the token from the connection options if it was provided.
if (token) {
if (connectionParams.token) {
debug('token sent via onConnect, attaching to the headers of the upgrade request');
// Attach it to the upgrade request.
connection.upgradeReq.headers['authorization'] = `Bearer ${token}`;
connection.upgradeReq.headers['authorization'] = `Bearer ${connectionParams.token}`;
}
// Call all the hooks.
await Promise.all(hooks.onConnect.map((hook) => hook(connectionParams, connection)));
};
const onOperation = (parsedMessage, baseParams, connection) => {
@@ -52,6 +79,9 @@ const onOperation = (parsedMessage, baseParams, connection) => {
return baseParams;
};
const onDisconnect = (connection) =>
Promise.all(hooks.onDisconnect.map((hook) => hook(connection)));
/**
* This creates a new subscription manager.
*/
@@ -62,6 +92,7 @@ const createSubscriptionManager = (server) => new SubscriptionServer({
setupFunctions,
}),
onConnect,
onDisconnect,
onOperation,
keepAlive: ms(KEEP_ALIVE)
}, {
+1 -1
View File
@@ -285,7 +285,7 @@ da:
no_like_bio: "Jeg kan ikke lide denne biografi"
no_like_username: "Jeg kan ikke lide dette brugernavn"
other: "Andet"
permalink: "Link"
permalink: "Delen"
personal_info: "Denne kommentar afslører personligt identificerbare oplysninger"
post: "Post"
profile: "Profil"
+6 -1
View File
@@ -166,6 +166,11 @@ en:
minute: "minute"
minutes_plural: "minutes"
email:
suspended:
subject: "Your account has been suspended"
banned:
subject: "Your account has been banned"
body: "In accordance with The Coral Projects community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community."
confirm:
has_been_requested: "A email confirmation has been requested for the following account:"
to_confirm: "To confirm the account, please visit the following link:"
@@ -297,7 +302,7 @@ en:
no_like_bio: "I don't like this bio"
no_like_username: "I don't like this username"
other: Other
permalink: Link
permalink: Share
personal_info: "This comment reveals personally identifiable information"
post: Post
profile: Profile
+1 -1
View File
@@ -283,7 +283,7 @@ es:
no_like_bio: "No me gusta esta biografia"
no_like_username: "No me gusta este nombre de usuario"
other: Otro
permalink: Enlace
permalink: Compartir
personal_info: "Este comentario muestra información personal"
post: Publicar
profile: Perfil
+1 -1
View File
@@ -237,7 +237,7 @@ fr:
no_like_bio: "Je n'aime pas cette biographie"
no_like_username: "Je n'aime pas ce nom d'utilisateur"
other: Autre
permalink: Lien
permalink: Partager
personal_info: "Ce commentaire révèle des informations personnelles identifiables"
post: Publier
profile: Profil
+1 -1
View File
@@ -288,7 +288,7 @@ pt_BR:
no_like_bio: "Eu não gosto dessa descrição de perfil"
no_like_username: "Eu não gosto deste nome de usuário"
other: Outro
permalink: Link
permalink: Compartilhar
personal_info: "Este comentário revela informações de identificação pessoal"
post: Publicar
profile: Perfil
+6
View File
@@ -0,0 +1,6 @@
const i18n = require('../services/i18n');
module.exports = (req, res, next) => {
res.locals.t = i18n.request(req);
next();
};
+2 -2
View File
@@ -9,13 +9,13 @@ module.exports = {
globals_path: './test/e2e/globals',
selenium: {
start_process: true,
server_path: 'node_modules/selenium-standalone/.selenium/selenium-server/3.6.0-server.jar',
server_path: 'node_modules/selenium-standalone/.selenium/selenium-server/3.7.1-server.jar',
log_path: './test/e2e/',
host: '127.0.0.1',
port: 6666,
cli_args: {
'webdriver.chrome.driver': 'node_modules/selenium-standalone/.selenium/chromedriver/2.33-x64-chromedriver',
'webdriver.gecko.driver': 'node_modules/selenium-standalone/.selenium/geckodriver/0.19.0-x64-geckodriver',
'webdriver.gecko.driver': 'node_modules/selenium-standalone/.selenium/geckodriver/0.19.1-x64-geckodriver',
}
},
test_settings: {
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "3.8.2",
"version": "3.9.0",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"private": true,
@@ -78,7 +78,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",
"cli-table": "^0.3.1",
"clipboard": "^1.7.1",
@@ -160,6 +159,7 @@
"react-mdl": "^1.7.2",
"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",
@@ -167,6 +167,7 @@
"react-test-renderer": "15.5",
"react-toastify": "^1.5.0",
"react-transition-group": "^1.1.3",
"react-virtualized": "9.13.0",
"recompose": "^0.23.1",
"redux": "^3.6.0",
"redux-thunk": "^2.1.0",
+4 -3
View File
@@ -87,11 +87,12 @@ export default (tag, options = {}) => hoistStatics((WrappedComponent) => {
}
render() {
const {root, asset, comment, user, config} = this.props;
const {root, asset, comment, user, config, ...rest} = this.props;
const alreadyTagged = isTagged(comment.tags, TAG);
return <WrappedComponent
{...rest}
root={root}
asset={asset}
comment={comment}
@@ -134,9 +135,9 @@ export default (tag, options = {}) => hoistStatics((WrappedComponent) => {
${fragments.comment ? fragments.comment : ''}
`
}),
connect(mapStateToProps, mapDispatchToProps),
withAddTag,
withRemoveTag
withRemoveTag,
connect(mapStateToProps, mapDispatchToProps),
);
WithTags.displayName = `WithTags(${getDisplayName(WrappedComponent)})`;
+7 -2
View File
@@ -55,7 +55,12 @@ const hookSchemas = {
loaders: Joi.func().maxArity(1),
mutators: Joi.func().maxArity(1),
resolvers: Joi.object().pattern(/\w/, Joi.object().pattern(/(?:__resolveType|\w+)/, Joi.func())),
typeDefs: Joi.string()
typeDefs: Joi.string(),
schemaLevelResolveFunction: Joi.func(),
websockets: Joi.object({
onConnect: Joi.func(),
onDisconnect: Joi.func(),
}),
};
/**
@@ -172,7 +177,7 @@ class PluginSection {
if (this.required) {
return;
}
this.required = true;
this.plugins.forEach((plugin) => {
@@ -0,0 +1,16 @@
import {OPEN_FEATURED_DIALOG, CLOSE_FEATURED_DIALOG} from './constants';
export const openFeaturedDialog = (comment, asset) => ({
type: OPEN_FEATURED_DIALOG,
comment: {
id: comment.id,
tags: comment.tags,
},
asset: {
id: asset.id,
},
});
export const closeFeaturedDialog = () => ({
type: CLOSE_FEATURED_DIALOG,
});
@@ -0,0 +1,41 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 400px;
top: 50%;
transform: translateY(-50%);
padding: 20px;
border-radius: 4px;
}
.header {
color: black;
font-size: 1.5em;
font-weight: 500;
margin: 0 0 8px 0;
}
.close {
display: block;
position: absolute;
top: 24px;
right: 20px;
}
.cancel {
margin-right: 5px;
}
.perform {
min-width: 90px;
}
.buttons {
margin-top: 8px;
margin-bottom: 6px;
text-align: right;
}
.content {
margin-bottom: 20px;
}
@@ -0,0 +1,52 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import {Dialog} from 'coral-ui';
import styles from './FeaturedDialog.css';
import {t} from 'plugin-api/beta/client/services';
import Button from 'coral-ui/components/Button';
const FeaturedDialog = ({showFeaturedDialog, closeFeaturedDialog, postTag}) => {
const onPerform = async () => {
await postTag();
await closeFeaturedDialog();
};
return (
<Dialog
className={cn(styles.dialog, 'talk-featured-dialog')}
id="talkFeaturedDialog"
open={showFeaturedDialog}
onCancel={closeFeaturedDialog} >
<span className={styles.close} onClick={closeFeaturedDialog}>×</span>
<h2 className={styles.header}>{t('talk-plugin-featured-comments.feature_comment')}</h2>
<div className={styles.content}>
{t('talk-plugin-featured-comments.are_you_sure')}
</div>
<div className={styles.buttons}>
<Button
className={cn(styles.cancel, 'talk-featured-dialog-button-cancel')}
cStyle="cancel"
onClick={closeFeaturedDialog}
raised >
{t('talk-plugin-featured-comments.cancel')}
</Button>
<Button
className={cn(styles.perform, 'talk-featured-dialog-button-confirm')}
cStyle="black"
onClick={onPerform}
raised >
{t('talk-plugin-featured-comments.yes_feature_comment')}
</Button>
</div>
</Dialog>
);
};
FeaturedDialog.propTypes = {
showFeaturedDialog: PropTypes.bool.isRequired,
closeFeaturedDialog: PropTypes.func.isRequired,
};
export default FeaturedDialog;
@@ -1,9 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './ModTag.css';
import {t} from 'plugin-api/beta/client/services';
import {Icon} from 'plugin-api/beta/client/components/ui';
import {getErrorMessages} from 'plugin-api/beta/client/utils';
export default class ModTag extends React.Component {
constructor() {
@@ -29,17 +29,12 @@ export default class ModTag extends React.Component {
});
}
postTag = async () => {
try {
await this.props.postTag();
}
catch(err) {
this.props.notify('error', getErrorMessages(err));
}
openFeaturedDialog = (comment, asset) => {
this.props.openFeaturedDialog(comment, asset);
}
render() {
const {alreadyTagged, deleteTag} = this.props;
const {alreadyTagged, deleteTag, comment, asset} = this.props;
return alreadyTagged ? (
<span className={cn(styles.tag, styles.featured)}
@@ -51,10 +46,19 @@ export default class ModTag extends React.Component {
</span>
) : (
<span className={cn(styles.tag, {[styles.featured]: alreadyTagged})}
onClick={this.postTag} >
onClick={() => this.openFeaturedDialog(comment, asset)} >
<Icon name="star_outline" className={cn(styles.tagIcon)} />
{alreadyTagged ? t('talk-plugin-featured-comments.featured') : t('talk-plugin-featured-comments.feature')}
</span>
);
}
}
ModTag.propTypes = {
alreadyTagged: PropTypes.bool,
deleteTag: PropTypes.func,
notify: PropTypes.func,
openFeaturedDialog: PropTypes.func,
comment: PropTypes.object,
asset: PropTypes.object,
};
@@ -0,0 +1,4 @@
const prefix = 'TALK_FEATURED_COMMENTS_ACTIONS';
export const OPEN_FEATURED_DIALOG = `${prefix}_OPEN_FEATURED_DIALOG`;
export const CLOSE_FEATURED_DIALOG = `${prefix}_CLOSE_FEATURED_DIALOG`;
@@ -0,0 +1,23 @@
import {compose} from 'react-apollo';
import {bindActionCreators} from 'redux';
import FeaturedDialog from '../components/FeaturedDialog';
import {withTags, connect} from 'plugin-api/beta/client/hocs';
import {closeFeaturedDialog} from '../actions';
const mapStateToProps = ({talkPluginFeaturedComments: state}) => ({
showFeaturedDialog: state.showFeaturedDialog,
comment: state.comment,
asset: state.asset,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
closeFeaturedDialog,
}, dispatch);
const enhance = compose(
connect(mapStateToProps, mapDispatchToProps),
withTags('featured'),
);
export default enhance(FeaturedDialog);
@@ -65,6 +65,14 @@ const COMMENT_FEATURED_SUBSCRIPTION = gql`
commentFeatured(asset_id: $assetId) {
comment {
...${getDefinitionName(Comment.fragments.comment)}
status_history {
type
created_at
assigned_by {
id
username
}
}
}
user {
id
@@ -2,11 +2,13 @@ import ModTag from '../components/ModTag';
import {withTags, connect} from 'plugin-api/beta/client/hocs';
import {gql, compose} from 'react-apollo';
import {bindActionCreators} from 'redux';
import {openFeaturedDialog} from '../actions';
import {notify} from 'plugin-api/beta/client/actions/notification';
const mapDispatchToProps = (dispatch) =>
bindActionCreators({
notify,
openFeaturedDialog,
}, dispatch);
const fragments = {
@@ -24,4 +26,3 @@ const enhance = compose(
);
export default enhance(ModTag);
@@ -6,19 +6,22 @@ import update from 'immutability-helper';
import ModTag from './containers/ModTag';
import ModActionButton from './containers/ModActionButton';
import ModSubscription from './containers/ModSubscription';
import FeaturedDialog from './containers/FeaturedDialog';
import {gql} from 'react-apollo';
import reducer from './reducer';
import {findCommentInEmbedQuery} from 'coral-embed-stream/src/graphql/utils';
import {prependNewNodes} from 'plugin-api/beta/client/utils';
export default {
translations,
reducer,
slots: {
streamTabsPrepend: [Tab],
streamTabPanes: [TabPane],
commentInfoBar: [Tag],
moderationActions: [ModActionButton],
adminModeration: [ModSubscription],
adminModeration: [ModSubscription, FeaturedDialog],
adminCommentInfoBar: [ModTag],
},
mutations: {
@@ -0,0 +1,32 @@
import {OPEN_FEATURED_DIALOG, CLOSE_FEATURED_DIALOG} from './constants';
const initialState = {
showFeaturedDialog: false,
comment: {
id: null,
tags: []
},
asset: {
id: null,
},
};
export default function reducer(state = initialState, action) {
switch (action.type) {
case OPEN_FEATURED_DIALOG:
return {
...state,
comment: action.comment,
asset: action.asset,
showFeaturedDialog: true,
};
case CLOSE_FEATURED_DIALOG:
return {
...state,
featuredCommentId: null,
showFeaturedDialog: false,
};
default :
return state;
}
}
@@ -9,6 +9,10 @@ en:
notify_self_featured: 'The comment from {0} is now featured and approved'
notify_featured: '{0} featured and approved comment "{1}"'
notify_unfeatured: '{0} unfeatured comment "{1}"'
feature_comment: Feature comment?
are_you_sure: Are you sure you would like to feature this comment?
cancel: Cancel
yes_feature_comment: Yes, feature comment
es:
talk-plugin-featured-comments:
un_feature: Desmarcar
@@ -17,3 +21,7 @@ es:
featured_comments: Comentarios Remarcados
go_to_conversation: Ir al comentario
tooltip_description: Comentarios seleccionados por nuestro equipo que valen la pena ser leidos
feature_comment: Destacar comentario?
are_you_sure: Está seguro que desea destacar este comentario?
cancel: Cancelar
yes_feature_comment: Si, destacar comentario
+10 -10
View File
@@ -1,13 +1,12 @@
const accepts = require('accepts');
const apollo = require('graphql-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 pubsub = require('../middleware/pubsub');
@@ -64,6 +63,9 @@ if (!DISABLE_STATIC_SERVER) {
router.get('/embed.js.map', serveFile('../dist/embed.js.map'));
}
// Add the i18n middleware to all routes.
router.use(i18n);
//==============================================================================
// STATIC ROUTES
//==============================================================================
@@ -79,7 +81,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');
@@ -110,25 +112,25 @@ router.use('/api/v1/graph/ql', apollo.graphqlExpress(createGraphOptions));
if (process.env.NODE_ENV !== 'production') {
// Interactive graphiql interface.
router.use('/api/v1/graph/iql', (req, res) => {
router.use('/api/v1/graph/iql', staticTemplate, (req, res) => {
res.render('graphiql', {
endpointURL: `${req.app.locals.BASE_URL}api/v1/graph/ql`
endpointURL: 'api/v1/graph/ql'
});
});
// GraphQL documention.
// GraphQL documentation.
router.get('/admin/docs', (req, res) => {
res.render('admin/docs');
});
}
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'));
@@ -184,8 +186,6 @@ router.use('/', (err, req, res, next) => {
console.error(err);
}
i18n.init(req);
if (err instanceof errors.APIError) {
res.status(err.status);
res.render('error', {
+11 -2
View File
@@ -2,17 +2,26 @@
REPORTS_FOLDER=${CIRCLE_TEST_REPORTS:-./test/e2e/reports}
CIRCLE_BRANCH=${CIRCLE_BRANCH:-master}
E2E_DISABLE=${E2E_DISABLE:-false}
# Amount of retries before failure.
E2E_MAX_RETRIES=${E2E_MAX_RETRIES:-1}
# Timeout for WaitForConditions.
E2E_WAIT_FOR_TIMEOUT=${E2E_WAIT_FOR_TIMEOUT:-10000}
# Safari >= 8 has issues connecting to browserstack-local. Safari < 8 is too old.
# IE 64bit has issues with receiving keyboard input. Let's wait for them to fix it.
BROWSERS="chrome,firefox,edge" #ie safari
E2E_BROWSERS=${E2E_BROWSERS:-chrome,firefox,edge} #ie safari
if [[ "${E2E_DISABLE}" == "true" ]]; then
echo E2E is disabled.
exit
fi
if [[ "${CIRCLE_BRANCH}" == "master" && -n "$BROWSERSTACK_KEY" ]]; then
echo Testing on browserstack
yarn e2e --reports-folder "$REPORTS_FOLDER" --bs-key "$BROWSERSTACK_KEY" --retries "$E2E_MAX_RETRIES" --browsers $BROWSERS
yarn e2e --reports-folder "$REPORTS_FOLDER" --bs-key "$BROWSERSTACK_KEY" --retries "$E2E_MAX_RETRIES" --timeout "$E2E_WAIT_FOR_TIMEOUT" --browsers "$E2E_BROWSERS"
else
# When browserstack is not available test locally using chrome headless.
echo Testing locally
+7 -3
View File
@@ -59,7 +59,7 @@ function seleniumInstall() {
});
}
function nightwatch(env, config, reportsFolder, browserstack) {
function nightwatch(env, config, reportsFolder, browserstack, timeout) {
return new Promise((resolve, reject) => {
try {
const nw = childProcess.spawn(
@@ -71,6 +71,7 @@ function nightwatch(env, config, reportsFolder, browserstack) {
'BROWSERSTACK_KEY': browserstack.key,
'BROWSERSTACK_USER': browserstack.user,
'REPORTS_FOLDER': `${reportsFolder}/${env}`,
'WAIT_FOR_TIMEOUT': timeout,
}),
stdio: 'inherit',
});
@@ -111,13 +112,13 @@ function printSection(txt) {
console.log('*****************************'.magenta);
}
async function runBrowserTests(browsers, config, retries = 1, reportsFolder, browserstack) {
async function runBrowserTests(browsers, config, retries = 1, reportsFolder, browserstack, timeout) {
const succeeded = {};
for (let browser of browsers) {
for (let t = 0; t < retries + 1; t++) {
try {
printSection(`e2e test for ${browser} #${t}`);
await nightwatch(browser, config, reportsFolder, browserstack);
await nightwatch(browser, config, reportsFolder, browserstack, timeout);
succeeded[browser] = t;
console.log(`\n==> Succeeded e2e for ${browser} #${t}\n`.green);
break;
@@ -143,6 +144,7 @@ async function start(program) {
const date = new Date().toISOString()
.replace(/[T.]/g, '-')
.replace(/:/g, '');
const timeout = program.timeout;
const reportsFolder = `${program.reportsFolder}/${date}`;
let exitCode = 0;
let config = 'nightwatch.conf.js';
@@ -175,6 +177,7 @@ async function start(program) {
retries,
reportsFolder,
browserstack,
timeout,
);
if (!succeeded) {
exitCode = 1;
@@ -202,6 +205,7 @@ program
.option('-r, --retries [number]', 'Number of retries before failing', '1')
.option('--headless', 'Start in headless mode for local e2e')
.option('--reports-folder [folder]', 'Reports folder', './test/e2e/reports')
.option('--timeout [number]', 'Timeout for WaitForConditions', '10000')
.parse(process.argv);
start(program);
+1 -1
View File
@@ -1,3 +1,3 @@
<p><%= t('email.confirm.has_been_requested') %> <b><%= email %></b>.</p>
<p><%= t('email.confirm.to_confirm') %> <a href="<%= BASE_URL %>admin/confirm-email#<%= token %>">Confirm Email</a></p>
<p><%= t('email.confirm.to_confirm') %> <a href="<%= BASE_URL %>admin/confirm-email#<%= token %>"><%= t('email.confirm.confirm_email') %></a></p>
<p><%= t('email.confirm.if_you_did_not') %></p>
+76 -42
View File
@@ -1,57 +1,91 @@
const has = require('lodash/has');
const get = require('lodash/get');
const yaml = require('yamljs');
const da = yaml.load('./locales/da.yml');
const es = yaml.load('./locales/es.yml');
const en = yaml.load('./locales/en.yml');
const fr = yaml.load('./locales/fr.yml');
const pt_BR = yaml.load('./locales/pt_BR.yml');
const fs = require('fs');
const path = require('path');
const debug = require('debug')('talk:services:i18n');
const accepts = require('accepts');
const _ = require('lodash');
const yaml = require('yamljs');
const plugins = require('./plugins');
const {DEFAULT_LANG} = require('../config');
// default language
let defaultLanguage = 'en';
let language = defaultLanguage;
const languages = ['en', 'da', 'es', 'fr', 'pt_BR'];
const resolve = (...paths) => path.resolve(path.join(__dirname, '..', 'locales', ...paths));
const translations = Object.assign(en, es, fr, pt_BR, da);
// Load all the translations.
let translations = fs.readdirSync(resolve())
// Resolve all the filenames relative the the locales directory.
.map((filename) => resolve(filename))
// Translations are only yml/yaml files.
.filter((filename) => /\.(yaml|yml)$/.test(filename))
// Load the translation files from disk.
.map((filename) => fs.readFileSync(filename, 'utf8'))
// Load the translation files.
.reduce((packs, contents) => {
const pack = yaml.parse(contents);
return _.merge(packs, pack);
}, {});
// Create a list of all supported translations.
const languages = Object.keys(translations);
let loadedPluginTranslations = false;
const loadPluginTranslations = () => {
if (loadedPluginTranslations) {
return;
}
// Load the plugin translations.
plugins.get('server', 'translations').forEach(({plugin, translations: filename}) => {
debug(`added plugin '${plugin.name}'`);
const pack = yaml.parse(fs.readFileSync(filename, 'utf8'));
translations = _.merge(translations, pack);
});
loadedPluginTranslations = true;
};
const t = (language) => (key, ...replacements) => {
// Loads the translations into the translations array from plugins. This is
// done lazily to ensure that we don't have an import cycle.
loadPluginTranslations();
// Check if the translation exists on the object.
if (_.has(translations[language], key)) {
// Get the translation value.
let translation = _.get(translations[language], key);
// Replace any {n} with the arguments passed to this method.
replacements.forEach((str, n) => {
translation = translation.replace(new RegExp(`\\{${n}\\}`, 'g'), str);
});
return translation;
} else {
console.warn(`${key} language key not set`);
return key;
}
};
/**
* Exposes a service object to allow translations.
* @type {Object}
*/
const i18n = {
/**
* Create the new Task kue.
*/
init(req) {
request(req) {
const lang = accepts(req).language(languages);
language = lang ? lang : defaultLanguage;
},
const language = lang ? lang : DEFAULT_LANG;
/**
* 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;
}
return t(language);
},
t: t(DEFAULT_LANG),
};
module.exports = i18n;
+15 -4
View File
@@ -9,7 +9,8 @@ const kue = require('kue');
// singleton Queue instance. So you can configure and use only a single Queue
// object within your node.js process.
let queue = null;
const getQueue = () => {
let isManaging = false;
const getQueue = ({managed = false} = {}) => {
if (queue) {
return queue;
}
@@ -21,8 +22,16 @@ const getQueue = () => {
}
});
// Watch for stuck jobs to manage.
queue.watchStuckJobs(1000);
// If this is a managed queue, and we aren't managing yet, then start the
// management.
if (managed && !isManaging) {
// Watch for stuck jobs to manage.
queue.watchStuckJobs(60000);
// Mark that we've now started management routines.
isManaging = true;
}
return queue;
};
@@ -67,7 +76,9 @@ class Task {
* Process jobs for the queue.
*/
process(callback) {
return getQueue().process(this.name, callback);
// Get the queue in managed mode.
return getQueue({managed: true}).process(this.name, callback);
}
/**
+6 -5
View File
@@ -13,7 +13,8 @@ const {
SMTP_USERNAME,
SMTP_PORT,
SMTP_PASSWORD,
SMTP_FROM_ADDRESS
SMTP_FROM_ADDRESS,
EMAIL_SUBJECT_PREFIX,
} = require('../config');
// load all the templates as strings
@@ -95,12 +96,12 @@ const mailer = module.exports = {
}
// Prefix the subject with `[Talk]`.
subject = `[Talk] ${subject}`;
subject = `${EMAIL_SUBJECT_PREFIX} ${subject}`;
attachLocals(locals);
// Attach the templating function.
locals['t'] = i18n.t;
// Attach the translation function.
locals.t = i18n.t;
return Promise.all([
@@ -112,7 +113,7 @@ const mailer = module.exports = {
])
.then(([html, text]) => {
// Create the job.
// Create the job.
return mailer.task.create({
title: 'Mail',
message: {
+2 -1
View File
@@ -2,6 +2,7 @@ const SettingModel = require('../models/setting');
const cache = require('./cache');
const errors = require('../errors');
const {dotize} = require('./utils');
const {SETTINGS_CACHE_TIME} = require('../config');
/**
* The selector used to uniquely identify the settings document.
@@ -35,7 +36,7 @@ module.exports = class SettingsService {
if (process.env.NODE_ENV === 'production') {
// When in production, wrap the settings retrieval with a cache.
const settings = await cache.h.wrap('settings', fields, 60, () => retrieve(fields));
const settings = await cache.h.wrap('settings', fields, SETTINGS_CACHE_TIME / 1000, () => retrieve(fields));
return new SettingModel(settings);
}
+18 -21
View File
@@ -24,6 +24,7 @@ const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha
const ActionsService = require('./actions');
const MailerService = require('./mailer');
const Wordlist = require('./wordlist');
const i18n = require('./i18n');
const Domainlist = require('./domainlist');
const {escapeRegExp} = require('./regex');
@@ -430,16 +431,14 @@ module.exports = class UsersService {
if (status === 'BANNED') {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (localProfile) {
const options =
{
template: 'banned', // needed to know which template to render!
locals: { // specifies the template locals.
body: 'In accordance with The Coral Projects community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community.'
},
subject: 'Your account has been banned',
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
const options = {
template: 'banned',
locals: {
body: i18n.t('email.banned.body'),
},
subject: i18n.t('email.banned.subject'),
to: localProfile.id
};
await MailerService.sendSimple(options);
}
}
@@ -467,16 +466,14 @@ module.exports = class UsersService {
if (message) {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (localProfile) {
const options =
{
template: 'suspension', // needed to know which template to render!
locals: { // specifies the template locals.
body: message
},
subject: 'Your account has been suspended',
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
const options = {
template: 'suspension',
locals: {
body: message
},
subject: i18n.t('email.suspended.subject'),
to: localProfile.id,
};
await MailerService.sendSimple(options);
}
@@ -509,7 +506,7 @@ module.exports = class UsersService {
locals: { // specifies the template locals.
body: message
},
subject: 'Email Suspension',
subject: i18n.t('email.suspended.subject'),
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
+1 -1
View File
@@ -11,7 +11,7 @@ module.exports = {
shutdown();
done();
},
waitForConditionTimeout: 5000,
waitForConditionTimeout: parseInt(process.env.WAIT_FOR_TIMEOUT) || 10000,
testData: {
admin: {
email: 'admin@test.com',
+8 -4
View File
@@ -57,6 +57,10 @@ module.exports = {
suspendUserDialog: '.talk-admin-suspend-user-dialog',
suspendUserConfirmButton: '.talk-admin-suspend-user-dialog-confirm',
supendUserSendButton: '.talk-admin-suspend-user-dialog-send',
usernameDialog: '.talk-reject-username-dialog',
usernameDialogButtons: '.talk-reject-username-dialog-buttons',
usernameDialogSuspend: '.talk-reject-username-dialog-button-k',
usernameDialogSuspensionMessage: '.talk-reject-username-dialog-suspension-message',
toast: '.toastify',
toastClose: '.toastify__close',
},
@@ -97,10 +101,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: {
@@ -137,6 +137,10 @@ module.exports = {
this.parent
.click('@drawerOverlay')
.waitForElementNotPresent('@drawerOverlay');
// Wait a bit to let animations terminate cleanly.
this.api.pause(200);
return this.parent;
},
}],
+5 -16
View File
@@ -62,24 +62,12 @@ module.exports = {
// Focusing on the Login PopUp
windowHandler.windowHandles((handles) => {
this.api.switchWindow(handles[1]);
});
const popup = this.api.page.popup().ready();
callback(popup);
const popup = this.api.page.popup().ready();
callback(popup);
// Give a tiny bit of time to let popup close.
this.api.pause(50);
if (this.api.capabilities.browserName === 'MicrosoftEdge') {
// More time for edge.
// https://www.browserstack.com/automate/builds/1ceccf4efb4683b7feb890f45a32b5922b40ed3f/sessions/7393dbfda8387e43b6d5851f359b0c07db414973
this.api.pause(1000);
}
// Focusing on the Embed Window
windowHandler.windowHandles((handles) => {
this.api.switchWindow(handles[0]);
// Focus on the Embed Window.
windowHandler.pop();
// For some reasons firefox does not automatically load auth after login.
// https://www.browserstack.com/automate/builds/37650cb4e66c6edce0ba0800a1c1b7e7f74bf991/sessions/7a4e9da69b0f9ecdf8b7fa9150639e47b1532cb0#automate_button
@@ -89,6 +77,7 @@ module.exports = {
this.parent.switchToIframe();
}
});
return this;
},
logout() {
+16 -5
View File
@@ -64,15 +64,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')
.waitForElementVisible('@usernameDialogSuspensionMessage')
.click('@usernameDialogSuspend')
.click('@usernameDialogSuspend');
community
.waitForElementNotPresent('@flaggedUser');
},
'admin logs out': (client) => {
@@ -94,6 +97,14 @@ module.exports = {
comments
.waitForElementVisible('@restrictedMessageBox');
},
'user should not be able to comment': (client) => {
const embedStream = client.page.embedStream();
const comments = embedStream.section.comments;
comments
.waitForElementNotPresent('@commentBoxTextarea')
.waitForElementNotPresent('@commentBoxPostButton');
},
'user picks another username': (client) => {
const embedStream = client.page.embedStream();
const comments = embedStream.section.comments;
@@ -106,12 +117,12 @@ module.exports = {
.click('@suspendedAccountSubmitButton')
.waitForElementNotPresent('@suspendedAccountInput');
},
'user should not be able to comment': (client) => {
'user should be able to comment': (client) => {
const embedStream = client.page.embedStream();
const comments = embedStream.section.comments;
comments
.waitForElementNotPresent('@commentBoxTextarea')
.waitForElementNotPresent('@commentBoxPostButton');
.waitForElementVisible('@commentBoxTextarea')
.waitForElementVisible('@commentBoxPostButton');
},
};
+14
View File
@@ -25,8 +25,14 @@ class SortedWindowHandler {
*/
windowHandles(callback) {
this.client.windowHandles((result) => {
if (result.value.message) {
throw new Error(result.value.message);
}
this.handles = this.handles.filter((handle) => result.value.includes(handle));
const remaining = result.value.filter((handle) => !this.handles.includes(handle));
if (remaining.length === 1) {
this.handles.push(remaining[0]);
}
@@ -36,6 +42,14 @@ class SortedWindowHandler {
callback(this.handles);
});
}
/**
* Switch to main window handle and remove latest handle.
*/
pop() {
this.client.switchWindow(this.handles[0]);
this.handles.splice(-1, 1);
}
}
module.exports = SortedWindowHandler;
+1 -1
View File
@@ -50,7 +50,7 @@
}
}
// We don't use safe-serialize for location, because it's not client input.
var fetchURL = locationQuery(otherParams, '<%= endpointURL %>');
var fetchURL = locationQuery(otherParams, '<%= BASE_URL %><%= endpointURL %>');
// Defines a GraphQL fetcher using the fetch API.
function graphQLFetcher(graphQLParams) {
+343 -222
View File
File diff suppressed because it is too large Load Diff