Compare commits

..
92 Commits
Author SHA1 Message Date
Kim Gardner b60c879599 Merge pull request #849 from coralproject/pluggable-modqueues
Pluggable modqueues
2017-08-10 17:32:36 +01:00
Kiwi 0f93d6ce8a Merge branch 'master' into pluggable-modqueues 2017-08-10 18:01:06 +02:00
Kim Gardner b8ffe51812 Merge pull request #838 from coralproject/setup-fixes
Added whitelist question to cli-setup
2017-08-10 16:47:18 +01:00
Kim Gardner bf298b311a Merge branch 'master' into setup-fixes 2017-08-10 15:24:16 +01:00
Kim Gardner b1d9ca0640 Merge pull request #846 from coralproject/bump-version-3-1-0
Update version number to 3.1.0
2017-08-10 15:24:03 +01:00
Kim Gardner c0ac5b0799 Merge branch 'master' into bump-version-3-1-0 2017-08-10 14:56:16 +01:00
Kim Gardner 2039cc5b9e Merge pull request #845 from coralproject/cookie-updates
Cookie updates
2017-08-10 14:51:27 +01:00
Wyatt Johnson bb2284909f Merge branch 'master' into cookie-updates 2017-08-10 23:24:03 +10:00
Wyatt Johnson f018d7f597 Merge pull request #847 from coralproject/fix-tag-remove
Remove Tag should keep remaining tags
2017-08-10 23:23:40 +10:00
Chi Vinh Le 6794827a77 Support adding modqueues from plugins 2017-08-10 20:23:31 +07:00
Chi Vinh Le 6956b4cd31 Remove Tag should keep remaining tags 2017-08-10 18:39:02 +07:00
Chi Vinh Le 83ded4bda9 Refactor into queueConfig 2017-08-10 18:37:14 +07:00
Kim Gardner 1fea2f77c1 Merge branch 'master' into cookie-updates 2017-08-10 12:04:34 +01:00
Kim Gardner 80cfc663e3 Merge branch 'master' into bump-version-3-1-0 2017-08-10 11:52:05 +01:00
Kim Gardner a3cda1cf1c Merge pull request #844 from coralproject/pre-responsive-admin-cleanup
Pre responsive admin cleanup
2017-08-10 11:42:20 +01:00
Kim Gardner 0013c36686 Update version number to 3.1.0 2017-08-10 11:20:45 +01:00
Kim Gardner d947e103fc Merge branch 'master' into pre-responsive-admin-cleanup 2017-08-10 10:57:40 +01:00
Wyatt Johnson 6d70a7b20e improved code around cookie handling, added to docs 2017-08-10 10:46:11 +10:00
Wyatt Johnson b9243938bd Added support for changing the singing cookie name 2017-08-10 10:33:45 +10:00
IAmSamHankins ca422f0109 mod queue nav styling adjust 2017-08-09 10:23:03 -05:00
IAmSamHankins c62e7328c5 adjustments to top navigation and logo 2017-08-09 10:15:52 -05:00
Kim Gardner df286a31ad Merge branch 'master' into setup-fixes 2017-08-09 11:31:26 +01:00
Kim Gardner ca069317e9 Merge pull request #839 from coralproject/fix-bug-plugin-config
Pass config to plugins
2017-08-09 11:30:45 +01:00
Kim Gardner 7c7799d2c0 Merge branch 'master' into fix-bug-plugin-config 2017-08-09 11:10:24 +01:00
Kim Gardner 8d30129f47 Merge pull request #840 from coralproject/emit-events-view-more
Emit events when viewing new comments or replies
2017-08-09 11:09:35 +01:00
Kim Gardner a9ec160f95 Merge branch 'master' into emit-events-view-more 2017-08-09 10:52:59 +01:00
Kim Gardner 85836a3965 Merge branch 'master' into setup-fixes 2017-08-09 10:52:45 +01:00
Kim Gardner b10ed4e43e Merge pull request #842 from coralproject/paginate-my-comments
Paginate my comments in profile tab
2017-08-09 10:51:31 +01:00
Kim Gardner 1e00d75e3c Merge branch 'master' into paginate-my-comments 2017-08-09 10:26:18 +01:00
Kim Gardner 66b3d4ab00 Merge pull request #843 from coralproject/docs-fix
Docs Fixes
2017-08-09 10:25:54 +01:00
Kim Gardner 1ab662efda Merge branch 'master' into docs-fix 2017-08-09 10:15:40 +01:00
Wyatt Johnson d100128843 Merge pull request #836 from erikreyna/expose-comment-container-class
expose talk-stream-comment-container class
2017-08-09 13:23:36 +10:00
Wyatt Johnson 89e7f54a32 Merge branch 'master' into expose-comment-container-class 2017-08-09 11:24:18 +10:00
Wyatt Johnson e14d380d66 Merge pull request #834 from coralproject/update-my-comments
Update my comments after posting
2017-08-09 11:24:06 +10:00
Wyatt Johnson fd87ef3b85 Merge branch 'master' into update-my-comments 2017-08-09 09:58:44 +10:00
Wyatt Johnson b260945822 Merge pull request #833 from coralproject/view-more-history
Support load more in user history
2017-08-09 09:58:29 +10:00
Wyatt Johnson 1521ec43eb Merge branch 'master' into view-more-history 2017-08-09 09:39:53 +10:00
Wyatt Johnson 5ebdfa8c23 Merge pull request #837 from erikreyna/expose-avatar-container-class
expose talk-stream-comment-avatar class
2017-08-09 09:39:38 +10:00
Wyatt Johnson 6913fe05ae fixes #841 2017-08-09 09:38:09 +10:00
Wyatt Johnson 41b2af76c4 Merge branch 'master' into setup-fixes 2017-08-09 09:27:24 +10:00
Kiwi 9f1bce1221 Merge branch 'master' into update-my-comments 2017-08-08 17:55:22 +02:00
Kiwi 528c2a9491 Merge branch 'master' into view-more-history 2017-08-08 17:55:17 +02:00
Kiwi 948d362c40 Merge branch 'master' into expose-avatar-container-class 2017-08-08 17:54:52 +02:00
Kiwi 30842362cf Merge branch 'master' into expose-comment-container-class 2017-08-08 17:54:46 +02:00
Chi Vinh Le 976bd1e9ad Paginate my comments in profile tab 2017-08-08 22:39:24 +07:00
Chi Vinh Le 7bdd28cb9c Emit ui.AllCommentsPane.viewNewComments and ui.Comment.showMoreReplies 2017-08-08 20:48:52 +07:00
Wyatt Johnson e16dd14e47 fix bug with impl 2017-08-08 23:33:07 +10:00
Kiwi c9f2c27a69 Merge branch 'master' into fix-bug-plugin-config 2017-08-08 15:08:00 +02:00
Chi Vinh Le c4a91d225e Pass config also to withTags 2017-08-08 19:57:23 +07:00
Chi Vinh Le d1fc5668fb Pass config to plugins 2017-08-08 19:50:59 +07:00
Wyatt Johnson aad245fcbf Merge pull request #835 from coralproject/fix-bugs-action-summaries
Fix bugs action summaries
2017-08-08 15:45:44 +10:00
Wyatt Johnson 49ad48d70f Merge branch 'master' into fix-bugs-action-summaries 2017-08-08 15:40:21 +10:00
Wyatt Johnson 800fd63549 Merge pull request #832 from coralproject/more-css-classes
More css classes
2017-08-08 15:40:10 +10:00
Wyatt Johnson c08d040d1d Added whitelist question to cli-setup 2017-08-08 15:37:45 +10:00
Erik Reyna 1e8d2ce3a6 expose talk-stream-comment-avatar class 2017-08-07 14:11:51 -04:00
Erik Reyna a22d0f7536 expose talk-stream-comment-container class 2017-08-07 14:00:53 -04:00
IAmSamHankins 53cac48e3b resizing of the comment component 2017-08-07 11:53:39 -05:00
IAmSamHankins 27216cbfdb hierarchy for asset search title 2017-08-07 10:32:21 -05:00
IAmSamHankins ddadd6c5d3 adjustments/fixes to approved rejected widths 2017-08-07 10:26:42 -05:00
Chi Vinh Le 10936d9f41 Sort not by count 2017-08-07 18:23:44 +07:00
Chi Vinh Le 1d5f1e676f Sort aggregation 2017-08-07 18:12:25 +07:00
Chi Vinh Le c2f8f41e59 Support rendering system flags 2017-08-07 18:12:07 +07:00
Wyatt Johnson 567f8bf94e adjusted wrapResponse function 2017-08-07 14:54:11 +10:00
IAmSamHankins 11b2bc265a comment tag/marker updates for size 2017-08-04 13:50:14 -04:00
IAmSamHankins 76b125f042 adjustments to comment queue counters 2017-08-04 13:42:19 -04:00
Chi Vinh Le d2a2f94531 Merge branch 'view-more-history' of ssh://github.com/coralproject/talk into view-more-history 2017-08-04 23:30:10 +07:00
Chi Vinh Le 0df4fd1e37 Adjust styling 2017-08-04 23:24:40 +07:00
Chi Vinh Le 5ceda4f2d2 Update my comments after posting 2017-08-04 22:35:26 +07:00
Kiwi 0523a1f828 Merge branch 'master' into view-more-history 2017-08-04 17:07:57 +02:00
Chi Vinh Le 12d3273d3d Support load more in user history 2017-08-04 22:02:44 +07:00
Kim Gardner e6330cd8c3 Merge branch 'master' into more-css-classes 2017-08-04 07:04:43 -04:00
Kim Gardner bc67bc7b78 Merge pull request #831 from coralproject/secrets-fixes
Improvements and fixes to JWT configuration
2017-08-04 07:04:32 -04:00
Kim Gardner a91a2d3fa3 Merge branch 'master' into secrets-fixes 2017-08-04 06:55:06 -04:00
Kim Gardner 32a8d78eb8 Merge pull request #827 from coralproject/correctly-resolve-isClosed
Correctly determine the status of the asset
2017-08-04 06:54:20 -04:00
Chi Vinh Le 203707124c More css classes 2017-08-04 16:55:02 +07:00
Wyatt Johnson a39b7237e6 updates to docs 2017-08-04 16:10:08 +10:00
Wyatt Johnson 190e93c8d7 fixed comment 2017-08-04 15:57:44 +10:00
Wyatt Johnson 02a75c590f lint fix 2017-08-04 15:54:14 +10:00
Wyatt Johnson 8cdf289b13 exposed more controls on jwt and added docs 2017-08-04 15:49:37 +10:00
Wyatt Johnson e435c323ec Merge branch 'master' into correctly-resolve-isClosed 2017-08-04 14:01:49 +10:00
Wyatt Johnson f92754a700 updated docs related to secrets 2017-08-04 13:54:42 +10:00
Wyatt Johnson 5c8130bece Added guard for private key length 2017-08-04 12:58:40 +10:00
Wyatt Johnson 8ceeb4b275 Merge branch 'master' into secrets-fixes 2017-08-04 10:38:42 +10:00
Wyatt Johnson 3ec81099f7 Merge pull request #830 from coralproject/embed-upgrade
Added support for embed/stream unloading
2017-08-04 10:38:06 +10:00
Wyatt Johnson c9b82008de Fixes to secrets init 2017-08-04 10:37:47 +10:00
Chi Vinh Le 5d2b9439d8 Add some comments 2017-08-03 21:50:16 +07:00
Chi Vinh Le b8f24b952b Automatically close stream when passed closedAt date 2017-08-03 21:41:01 +07:00
Chi Vinh Le b6d284495e Merge branch 'master' into correctly-resolve-isClosed 2017-08-03 21:00:28 +07:00
Chi Vinh Le a527990413 Merge branch 'master' into correctly-resolve-isClosed 2017-08-03 20:26:32 +07:00
Wyatt Johnson 325626fecf removed all emitter listeners when we remove the stream 2017-08-03 11:33:07 +10:00
Wyatt Johnson 15c7a7cad7 Added support for embed unloading 2017-08-03 11:23:06 +10:00
Chi Vinh Le b972f4305e Correctly determine the status of the asset 2017-08-02 00:04:11 +07:00
68 changed files with 1204 additions and 741 deletions
+32 -6
View File
@@ -94,7 +94,7 @@ const performSetup = async () => {
name: 'requireEmailConfirmation',
default: settings.requireEmailConfirmation,
message: 'Should emails always be confirmed'
}
},
]);
// Update the settings that were changed.
@@ -104,6 +104,32 @@ const performSetup = async () => {
}
});
answers = await inquirer.prompt([
{
type: 'confirm',
name: 'inputWhitelistedDomains',
default: true,
message: 'Would you like to specify a whitelisted domain'
},
{
type: 'input',
name: 'whitelistedDomain',
message: 'Whitelisted Domain',
when: ({inputWhitelistedDomains}) => inputWhitelistedDomains,
validate: (input) => {
if (input && input.length > 0) {
return true;
}
return 'Whitelisted Domain cannot be empty.';
}
}
]);
if (answers.inputWhitelistedDomains) {
settings.domains.whitelist = [answers.whitelistedDomain];
}
console.log('\nWe\'ll ask you some questions about your first admin user.\n');
let user = await inquirer.prompt([
@@ -147,7 +173,11 @@ const performSetup = async () => {
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
filter: (confirmPassword) => {
filter: (confirmPassword, {password}) => {
if (password !== confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
}
return UsersService
.isValidPassword(confirmPassword)
.catch((err) => {
@@ -157,10 +187,6 @@ const performSetup = async () => {
},
]);
if (user.password !== user.confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
}
let {user: newUser} = await SetupService.setup({
settings: settings.toObject(),
user: {
+4 -20
View File
@@ -37,27 +37,11 @@ const routes = (
<Route path='moderate' component={ModerationLayout}>
<IndexRoute components={Moderation} />
<Route path='all' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='new' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='approved' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='premod' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='rejected' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='reported' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path=':id' components={Moderation} />
<Route path=':tabOrId' components={Moderation} />
<Route path=':tab' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
</Route>
</Route>
</div>
@@ -3,11 +3,13 @@
color: white;
background: grey;
box-sizing: border-box;
padding: 2px 8px;
padding: 0px 5px;
border-radius: 2px;
font-size: 12px;
height: 28px;
height: 24px;
letter-spacing: 0.4px;
margin-bottom: 1px;
> i {
font-size: 14px;
vertical-align: text-top;
@@ -1,6 +1,6 @@
.flagBox {
border-top: 1px solid rgba(66, 66, 66, 0.12);
margin-top: 10px;
.container {
padding: 0 14px;
}
+5 -3
View File
@@ -61,9 +61,11 @@ class FlagBox extends Component {
<ul>
{actionList.map((action, j) =>
<li key={`${i}_${j}`} className={styles.subDetail}>
<a className={styles.username} onClick={() => viewUserDetail(action.user.id)}>
{action.user.username}
</a>
{action.user &&
<a className={styles.username} onClick={() => viewUserDetail(action.user.id)}>
{action.user.username}
</a>
}
{action.message}
</li>
)}
@@ -0,0 +1,21 @@
.loadMoreContainer {
display: flex;
justify-content: center;
width: 100%;
}
.loadMore {
width: 100%;
text-align: center;
color: #FFF;
max-width: 660px;
margin-bottom: 30px;
background-color: #2376D8;
cursor: pointer;
}
.loadMore:hover {
background-color: #4399FF;
}
@@ -1,9 +1,10 @@
import React, {PropTypes} from 'react';
import {Button} from 'coral-ui';
import styles from './styles.css';
import styles from './LoadMore.css';
import cn from 'classnames';
const LoadMore = ({loadMore, showLoadMore}) =>
<div className={styles.loadMoreContainer}>
const LoadMore = ({loadMore, showLoadMore, className, ...rest}) =>
<div {...rest} className={cn(className, styles.loadMoreContainer)}>
{
showLoadMore && <Button
className={styles.loadMore}
@@ -186,7 +186,6 @@
.actionButton {
transform: scale(.8);
margin: 0;
width: 140px;
}
.minimal {
@@ -79,3 +79,12 @@
margin-left: -10px;
}
}
.loadMore > button {
background-color: #696969;
&:hover {
background-color: #404040;
color: white;
}
}
@@ -6,6 +6,7 @@ import {Slot} from 'coral-framework/components';
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
import {actionsMap} from '../utils/moderationQueueActionsMap';
import ClickOutside from 'coral-framework/components/ClickOutside';
import LoadMore from '../components/LoadMore';
export default class UserDetail extends React.Component {
@@ -59,7 +60,7 @@ export default class UserDetail extends React.Component {
user,
totalComments,
rejectedComments,
comments: {nodes}
comments: {nodes, hasNextPage}
},
activeTab,
selectedCommentIds,
@@ -70,6 +71,7 @@ export default class UserDetail extends React.Component {
bulkReject,
hideUserDetail,
viewUserDetail,
loadMore,
} = this.props;
const localProfile = user.profiles.find((p) => p.provider === 'local');
@@ -167,6 +169,11 @@ export default class UserDetail extends React.Component {
})
}
</div>
<LoadMore
className={styles.loadMore}
loadMore={loadMore}
showLoadMore={hasNextPage}
/>
</Drawer>
</ClickOutside>
);
@@ -8,6 +8,10 @@
min-height: 0;
}
.root:last-child {
border: 0;
}
.rootSelected {
background-color: #ecf4ff;
}
@@ -10,16 +10,18 @@
.logo span {
display: inline-block;
margin-left: 10px;
font-size: 18px;
font-size: 26px;
vertical-align: middle;
font-weight: 500;
color: white;
}
.logo {
background: #E5E5E5;
background: #696969;
height: 100%;
width: 128px;
z-index: 10;
border-right: 1px #757575 solid;
}
.base {
@@ -14,6 +14,7 @@ import {
} from 'coral-admin/src/actions/userDetail';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import UserDetailComment from './UserDetailComment';
import update from 'immutability-helper';
const commentConnectionFragment = gql`
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
@@ -32,6 +33,7 @@ const slots = [
];
class UserDetailContainer extends React.Component {
isLoadingMore = false;
// status can be 'ACCEPTED' or 'REJECTED'
bulkSetCommentStatus = (status) => {
@@ -40,7 +42,6 @@ class UserDetailContainer extends React.Component {
});
Promise.all(changes).then(() => {
this.props.data.refetch(); // some comments may have moved out of this tab
this.props.clearUserDetailSelections(); // un-select everything
});
}
@@ -61,12 +62,53 @@ class UserDetailContainer extends React.Component {
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
}
loadMore = () => {
if (this.isLoadingMore) {
return;
}
this.isLoadingMore = true;
const variables = {
limit: 10,
cursor: this.props.root.comments.endCursor,
author_id: this.props.data.variables.author_id,
statuses: this.props.data.variables.statuses,
};
this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables,
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
return update(prev, {
comments: {
nodes: {$push: comments.nodes},
hasNextPage: {$set: comments.hasNextPage},
startCursor: {$set: comments.startCursor},
endCursor: {$set: comments.endCursor},
},
});
}
})
.then(() => {
this.isLoadingMore = false;
})
.catch((err) => {
this.isLoadingMore = false;
throw err;
});
};
componentWillReceiveProps(next) {
if (this.props.userId === null && next.userId) {
next.data.refetch();
}
}
render () {
if (!this.props.userId) {
return null;
}
const loading = !('user' in this.props.root) || this.props.root.user.id !== this.props.userId;
const loading = [1, 2, 4].indexOf(this.props.data.networkStatus) >= 0;
return <UserDetail
bulkReject={this.bulkReject}
@@ -76,10 +118,20 @@ class UserDetailContainer extends React.Component {
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
loading={loading}
loadMore={this.loadMore}
{...this.props} />;
}
}
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
...CoralAdmin_Moderation_CommentConnection
}
}
${commentConnectionFragment}
`;
export const withUserDetailQuery = withQuery(gql`
query CoralAdmin_UserDetail($author_id: ID!, $statuses: [COMMENT_STATUS!]) {
user(id: $author_id) {
@@ -1,16 +1,16 @@
.count {
display: inline-block;
background: #989797;
background: #616161;
margin: 2px;
vertical-align: middle;
padding: 1px 7px;
padding: 1px 5px;
border-radius: 2px;
margin-left: 2px;
line-height: 20px;
line-height: 18px;
box-sizing: border-box;
height: 21px;
height: 18px;
right: 0;
margin-top: -2px;
margin-top: 0px;
font-size: 12px;
color: white;
}
@@ -101,33 +101,19 @@ export default class Moderation extends Component {
}
render () {
const {root, data, moderation, settings, viewUserDetail, hideUserDetail, activeTab, getModPath, premodEnabled, ...props} = this.props;
const assetId = this.props.params.id;
const {root, data, moderation, settings, viewUserDetail, hideUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
const {asset} = root;
const assetId = asset && asset.id;
const comments = root[activeTab];
let activeTabCount;
switch(activeTab) {
case 'all':
activeTabCount = root.allCount;
break;
case 'new':
activeTabCount = root.newCount;
break;
case 'approved':
activeTabCount = root.approvedCount;
break;
case 'premod':
activeTabCount = root.premodCount;
break;
case 'reported':
activeTabCount = root.reportedCount;
break;
case 'rejected':
activeTabCount = root.rejectedCount;
break;
}
const activeTabCount = root[`${activeTab}Count`];
const menuItems = Object.keys(queueConfig).map((queue) => ({
key: queue,
name: queueConfig[queue].name,
icon: queueConfig[queue].icon,
count: root[`${queue}Count`]
}));
return (
<div>
@@ -139,16 +125,10 @@ export default class Moderation extends Component {
/>
<ModerationMenu
asset={asset}
allCount={root.allCount}
newCount={root.newCount}
getModPath={getModPath}
approvedCount={root.approvedCount}
premodCount={root.premodCount}
rejectedCount={root.rejectedCount}
reportedCount={root.reportedCount}
items={menuItems}
selectSort={this.props.setSortOrder}
sort={this.props.moderation.sortOrder}
premodEnabled={premodEnabled}
activeTab={activeTab}
/>
<ModerationQueue
@@ -191,6 +171,7 @@ export default class Moderation extends Component {
root={root}
assset={asset}
activeTab={activeTab}
handleCommentChange={handleCommentChange}
fill='adminModeration'
/>
</div>
@@ -9,16 +9,10 @@ import cn from 'classnames';
import t from 'coral-framework/services/i18n';
const ModerationMenu = ({
asset = {},
allCount,
approvedCount,
premodCount,
newCount,
rejectedCount,
reportedCount,
asset = {},
items,
selectSort,
sort,
premodEnabled,
getModPath,
activeTab
}) => {
@@ -27,49 +21,15 @@ const ModerationMenu = ({
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<div className={styles.tabBarPadding} />
<div>
{
premodEnabled ? (
<Link
to={getModPath('premod', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'premod'})}
activeClassName={styles.active}>
<Icon name='access_time' className={styles.tabIcon} /> {t('modqueue.premod')} <CommentCount count={premodCount} />
</Link>
) : (
<Link
to={getModPath('new', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'new'})}
activeClassName={styles.active}>
<Icon name='question_answer' className={styles.tabIcon} /> {t('modqueue.new')} <CommentCount count={newCount} />
</Link>
)
}
<Link
to={getModPath('reported', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'reported'})}
activeClassName={styles.active}>
<Icon name='flag' className={styles.tabIcon} /> {t('modqueue.reported')} <CommentCount count={reportedCount} />
</Link>
<Link
to={getModPath('approved', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'approved'})}
activeClassName={styles.active}>
<Icon name='check' className={styles.tabIcon} /> {t('modqueue.approved')} <CommentCount count={approvedCount} />
</Link>
<Link
to={getModPath('rejected', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'rejected'})}
activeClassName={styles.active}>
<Icon name='close' className={styles.tabIcon} /> {t('modqueue.rejected')} <CommentCount count={rejectedCount} />
</Link>
<Link
to={getModPath('all', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'all'})}
activeClassName={styles.active}>
<Icon name='question_answer' className={styles.tabIcon} /> {t('modqueue.all')} <CommentCount count={allCount} />
</Link>
{items.map((queue) =>
<Link
key={queue.key}
to={getModPath(queue.key, asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === queue.key})}
activeClassName={styles.active}>
<Icon name={queue.icon} className={styles.tabIcon} /> {queue.name} <CommentCount count={queue.count} />
</Link>
)}
</div>
<SelectField
className={styles.selectField}
@@ -85,10 +45,7 @@ const ModerationMenu = ({
};
ModerationMenu.propTypes = {
allCount: PropTypes.number.isRequired,
premodCount: PropTypes.number.isRequired,
rejectedCount: PropTypes.number.isRequired,
reportedCount: PropTypes.number.isRequired,
items: PropTypes.array.isRequired,
asset: PropTypes.shape({
id: PropTypes.string
})
@@ -4,7 +4,7 @@ import Comment from '../containers/Comment';
import styles from './styles.css';
import EmptyCard from '../../../components/EmptyCard';
import {actionsMap} from '../../../utils/moderationQueueActionsMap';
import LoadMore from './LoadMore';
import LoadMore from '../../../components/LoadMore';
import t from 'coral-framework/services/i18n';
import {CSSTransitionGroup} from 'react-transition-group';
@@ -18,7 +18,7 @@
.tab {
flex: 1;
color: #C0C0C0;
color: #BDBDBD;
text-transform: capitalize;
font-weight: 100;
font-size: 14px;
@@ -29,7 +29,7 @@
margin-right: 20px;
&:hover {
color: white;
border-bottom: solid 2px #F36451;
/*border-bottom: solid 2px #F36451;*/
box-sizing: border-box;
}
}
@@ -111,7 +111,7 @@ span {
color: white;
text-transform: capitalize;
font-weight: 400;
font-size: 15px;
font-size: 20px;
letter-spacing: 1px;
transition: background-color 200ms;
opacity: 1;
@@ -173,7 +173,7 @@ span {
border-bottom: 1px solid #e0e0e0;
font-size: 18px;
width: 100%;
max-width: 700px;
max-width: 650px;
min-width: 400px;
margin: 0 auto;
position: relative;
@@ -397,26 +397,6 @@ span {
}
}
.loadMoreContainer {
display: flex;
justify-content: center;
width: 100%;
};
.loadMore {
width: 100%;
text-align: center;
color: #FFF;
max-width: 660px;
margin-bottom: 30px;
background-color: #2376D8;
cursor: pointer;
}
.loadMore:hover {
background-color: #4399FF;
}
.tabIcon {
position: relative;
top: 3px;
@@ -490,7 +470,7 @@ span {
.searchTrigger {
position: relative;
top: .3em;
top: .2em;
}
.adminCommentInfoBar {
@@ -499,4 +479,4 @@ span {
right: 0px;
top: 0px;
text-align: right;
}
}
@@ -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/utils';
import {handleCommentChange} from '../graphql';
import {fetchSettings} from 'actions/settings';
import {showBanUserDialog} from 'actions/banUserDialog';
@@ -30,23 +30,44 @@ import {
import {Spinner} from 'coral-ui';
import Moderation from '../components/Moderation';
import Comment from './Comment';
import queueConfig from '../queueConfig';
function prepareNotificationText(text) {
return truncate(text, {length: 50}).replace('\n', ' ');
}
function getAssetId(props) {
if (props.params.tabOrId && !(props.params.tabOrId in queueConfig)) {
return props.params.tabOrId;
}
return props.params.id || null;
}
function getTab(props) {
if (props.params.tabOrId && props.params.tabOrId in queueConfig) {
return props.params.tabOrId;
}
return props.params.tab || null;
}
class ModerationContainer extends Component {
subscriptions = [];
handleCommentChange = (root, comment, notify) => {
return handleCommentChange(root, comment, this.props.data.variables.sort, notify, queueConfig, this.activeTab);
};
get activeTab() {
const {root: {asset, settings}, router, route} = this.props;
const {root: {asset, settings}} = this.props;
const id = getAssetId(this.props);
const tab = getTab(this.props);
// Grab premod from asset or from settings
const premod = !router.params.id ? settings.moderation : asset.settings.moderation;
const premod = !id ? settings.moderation : asset.settings.moderation;
const queue = isPremod(premod) ? 'premod' : 'new';
const activeTab = route.path && route.path !== ':id' ? route.path : queue;
const activeTab = tab ? tab : queue;
return activeTab;
}
@@ -57,15 +78,10 @@ class ModerationContainer extends Component {
variables,
updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const sort = this.props.moderation.sortOrder;
const notify = this.props.auth.user.id === user.id
? {}
: {
activeQueue: this.activeTab,
text: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)),
anyQueue: false,
};
return handleCommentChange(prev, comment, sort, notify);
? ''
: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
},
});
@@ -74,15 +90,10 @@ class ModerationContainer extends Component {
variables,
updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => {
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
const sort = this.props.moderation.sortOrder;
const notify = this.props.auth.user.id === user.id
? {}
: {
activeQueue: this.activeTab,
text: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)),
anyQueue: false,
};
return handleCommentChange(prev, comment, sort, notify);
? ''
: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
},
});
@@ -90,13 +101,8 @@ class ModerationContainer extends Component {
document: COMMENT_EDITED_SUBSCRIPTION,
variables,
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
const sort = this.props.moderation.sortOrder;
const notify = {
activeQueue: this.activeTab,
text: t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)),
anyQueue: false,
};
return handleCommentChange(prev, comment, sort, notify);
const notify = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
},
});
@@ -105,13 +111,8 @@ class ModerationContainer extends Component {
variables,
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
const user = comment.actions[comment.actions.length - 1].user;
const sort = this.props.moderation.sortOrder;
const notify = {
activeQueue: this.activeTab,
text: t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)),
anyQueue: true,
};
return handleCommentChange(prev, comment, sort, notify);
const notify = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body));
return this.handleCommentChange(prev, comment, notify);
},
});
@@ -160,28 +161,9 @@ class ModerationContainer extends Component {
cursor: this.props.root[tab].endCursor,
sort: this.props.data.variables.sort,
asset_id: this.props.data.variables.asset_id,
statuses: queueConfig[tab].statuses,
action_type: queueConfig[tab].action_type,
};
switch(tab) {
case 'all':
variables.statuses = null;
break;
case 'new':
variables.statuses = ['NONE', 'PREMOD'];
break;
case 'approved':
variables.statuses = ['ACCEPTED'];
break;
case 'premod':
variables.statuses = ['PREMOD'];
break;
case 'reported':
variables.statuses = ['NONE', 'PREMOD'];
variables.action_type = 'FLAG';
break;
case 'rejected':
variables.statuses = ['REJECTED'];
break;
}
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables,
@@ -199,7 +181,8 @@ class ModerationContainer extends Component {
};
render () {
const {root, root: {asset, settings}, data, params: {id: assetId}} = this.props;
const {root, root: {asset, settings}, data} = this.props;
const assetId = getAssetId(this.props);
if (data.error) {
return <div>Error</div>;
@@ -222,6 +205,14 @@ class ModerationContainer extends Component {
return <Spinner />;
}
const premodEnabled = assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation);
const currentQueueConfig = Object.assign({}, queueConfig);
if (premodEnabled) {
delete currentQueueConfig.new;
} else {
delete currentQueueConfig.premod;
}
return <Moderation
{...this.props}
getModPath={getModPath}
@@ -229,7 +220,8 @@ class ModerationContainer extends Component {
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
activeTab={this.activeTab}
premodEnabled={assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation)}
queueConfig={currentQueueConfig}
handleCommentChange={this.handleCommentChange}
/>;
}
}
@@ -314,49 +306,25 @@ const commentConnectionFragment = gql`
const withModQueueQuery = withQuery(gql`
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
all: comments(query: {
statuses: [NONE, PREMOD, ACCEPTED, REJECTED],
asset_id: $asset_id,
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
new: comments(query: {
statuses: [NONE, PREMOD],
asset_id: $asset_id,
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
approved: comments(query: {
statuses: [ACCEPTED],
asset_id: $asset_id,
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
premod: comments(query: {
statuses: [PREMOD],
${Object.keys(queueConfig).map((queue) => `
${queue}: comments(query: {
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
asset_id: $asset_id,
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
reported: comments(query: {
action_type: FLAG,
}) {
...CoralAdmin_Moderation_CommentConnection
}
`)}
${Object.keys(queueConfig).map((queue) => `
${queue}Count: commentCount(query: {
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
asset_id: $asset_id,
statuses: [NONE, PREMOD],
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
rejected: comments(query: {
statuses: [REJECTED],
asset_id: $asset_id,
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
})
`)}
asset(id: $asset_id) @skip(if: $allAssets) {
id
title
@@ -365,30 +333,6 @@ const withModQueueQuery = withQuery(gql`
moderation
}
}
allCount: commentCount(query: {
asset_id: $asset_id
})
newCount: commentCount(query: {
statuses: [NONE, PREMOD],
asset_id: $asset_id
})
approvedCount: commentCount(query: {
statuses: [ACCEPTED],
asset_id: $asset_id
})
premodCount: commentCount(query: {
statuses: [PREMOD],
asset_id: $asset_id
})
rejectedCount: commentCount(query: {
statuses: [REJECTED],
asset_id: $asset_id
})
reportedCount: commentCount(query: {
action_type: FLAG,
asset_id: $asset_id,
statuses: [NONE, PREMOD]
})
settings {
organizationName
moderation
@@ -396,11 +340,12 @@ const withModQueueQuery = withQuery(gql`
}
${commentConnectionFragment}
`, {
options: ({params: {id = null}, moderation: {sortOrder}}) => {
options: (props) => {
const id = getAssetId(props);
return {
variables: {
asset_id: id,
sort: sortOrder,
sort: props.moderation.sortOrder,
allAssets: id === null
}
};
@@ -409,33 +354,18 @@ const withModQueueQuery = withQuery(gql`
const withQueueCountPolling = withQuery(gql`
query CoralAdmin_ModerationCountPoll($asset_id: ID) {
allCount: commentCount(query: {
asset_id: $asset_id
})
newCount: commentCount(query: {
statuses: [NONE, PREMOD],
asset_id: $asset_id
})
approvedCount: commentCount(query: {
statuses: [ACCEPTED],
asset_id: $asset_id
})
premodCount: commentCount(query: {
statuses: [PREMOD],
asset_id: $asset_id
})
rejectedCount: commentCount(query: {
statuses: [REJECTED],
asset_id: $asset_id
})
reportedCount: commentCount(query: {
action_type: FLAG,
asset_id: $asset_id,
statuses: [NONE, PREMOD]
})
${Object.keys(queueConfig).map((queue) => `
${queue}Count: commentCount(query: {
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
asset_id: $asset_id,
})
`)}
}
`, {
options: ({params: {id = null}}) => {
options: (props) => {
const id = getAssetId(props);
return {
pollInterval: 5000,
variables: {
@@ -1,7 +1,6 @@
import update from 'immutability-helper';
import * as notification from 'coral-admin/src/services/notification';
const queues = ['all', 'premod', 'reported', 'approved', 'rejected', 'new'];
const limit = 10;
const ascending = (a, b) => {
@@ -67,32 +66,24 @@ function addCommentToQueue(root, queue, comment, sort) {
/**
* getCommentQueues determines in which queues a comment should be placed.
*/
function getCommentQueues(comment) {
const queues = ['all'];
const isFlagged = comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction');
switch(comment.status) {
case 'ACCEPTED':
queues.push('approved');
break;
case 'REJECTED':
queues.push('rejected');
break;
case 'PREMOD':
queues.push('premod');
queues.push('new');
if (isFlagged) {
queues.push('reported');
function getCommentQueues(comment, queueConfig) {
const queues = [];
Object.keys(queueConfig).forEach((key) => {
const {action_type, statuses, tags} = queueConfig[key];
let addToQueues = false;
if (statuses && statuses.indexOf(comment.status) >= 0) {
addToQueues = true;
}
break;
case 'NONE':
queues.push('new');
if (isFlagged) {
queues.push('reported');
if (tags && comment.tags && comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0)) {
addToQueues = true;
}
break;
}
if (action_type && comment.actions && comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type}action`)) {
addToQueues = true;
}
if (addToQueues) {
queues.push(key);
}
});
return queues;
}
@@ -106,42 +97,42 @@ function getCommentQueues(comment) {
* @param {string} notify.text notification text to show
* @param {bool} notify.anyQueue if true show the notification when the comment is shown
* in the current active queue besides the 'all' queue.
* @param {Object} queueConfig queue configuration
* @return {Object} next state of the store
*/
export function handleCommentChange(root, comment, sort, notify) {
export function handleCommentChange(root, comment, sort, notify, queueConfig, activeQueue) {
let next = root;
const nextQueues = getCommentQueues(comment);
const nextQueues = getCommentQueues(comment, queueConfig);
let notificationShown = false;
const showNotificationOnce = () => {
if (notificationShown) {
return;
}
notification.info(notify.text);
notification.info(notify);
notificationShown = true;
};
queues.forEach((queue) => {
Object.keys(queueConfig).forEach((queue) => {
if (nextQueues.indexOf(queue) >= 0) {
if (!queueHasComment(next, queue, comment.id)) {
next = addCommentToQueue(next, queue, comment, sort);
if (notify && notify.activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
showNotificationOnce(comment);
}
}
} else if(queueHasComment(next, queue, comment.id)){
next = removeCommentFromQueue(next, queue, comment.id);
if (notify && notify.activeQueue === queue) {
if (notify && activeQueue === queue) {
showNotificationOnce(comment);
}
}
if (
notify
&& (queue === 'all' || notify.anyQueue)
&& queueHasComment(next, queue, comment.id)
&& notify.activeQueue === queue
&& activeQueue === queue
) {
showNotificationOnce(comment);
}
@@ -0,0 +1,37 @@
import t from 'coral-framework/services/i18n';
import {getModQueueConfigs} from 'coral-framework/helpers/plugins';
export default {
premod: {
statuses: ['PREMOD'],
icon: 'access_time',
name: t('modqueue.premod'),
},
new: {
statuses: ['NONE', 'PREMOD'],
icon: 'question_answer',
name: t('modqueue.new'),
},
reported: {
action_type: 'FLAG',
statuses: ['NONE', 'PREMOD'],
icon: 'flag',
name: t('modqueue.reported'),
},
approved: {
statuses: ['ACCEPTED'],
icon: 'check',
name: t('modqueue.approved'),
},
rejected: {
statuses: ['REJECTED'],
icon: 'close',
name: t('modqueue.rejected'),
},
all: {
statuses: ['NONE', 'PREMOD', 'ACCEPTED', 'REJECTED'],
icon: 'question_answer',
name: t('modqueue.all'),
},
...getModQueueConfigs(),
};
@@ -16,7 +16,7 @@ class ConfigureStreamContainer extends Component {
this.state = {
changed: false,
dirtySettings: props.asset.settings,
closedAt: (props.asset.closedAt === null ? 'open' : 'closed')
closedAt: !props.asset.isClosed ? 'open' : 'closed'
};
this.toggleStatus = this.toggleStatus.bind(this);
@@ -1,6 +1,6 @@
import pym from 'coral-framework/services/pym';
import * as actions from '../constants/stream';
import {buildUrl} from 'coral-framework/utils';
import {buildUrl} from 'coral-framework/utils/url';
import queryString from 'query-string';
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
@@ -93,6 +93,7 @@ class AllCommentsPane extends React.Component {
viewNewComments = () => {
this.setState(resetCursors);
this.props.emit('ui.AllCommentsPane.viewNewComments');
};
// getVisibileComments returns a list containing comments
@@ -142,6 +143,7 @@ class AllCommentsPane extends React.Component {
charCountEnable,
maxCharCount,
editComment,
emit,
} = this.props;
const {loadingState} = this.state;
@@ -181,6 +183,7 @@ class AllCommentsPane extends React.Component {
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
editComment={editComment}
emit={emit}
/>;
})}
</TransitionGroup>
@@ -224,6 +224,7 @@ export default class Comment extends React.Component {
return;
}
this.setState(resetCursors);
this.props.emit('ui.Comment.showMoreReplies');
};
showReplyBox = () => {
@@ -400,13 +401,13 @@ export default class Comment extends React.Component {
<div className={commentClassName}>
<Slot
className={styles.commentAvatar}
className={`${styles.commentAvatar} talk-stream-comment-avatar`}
fill="commentAvatar"
{...slotProps}
inline
/>
<div className={styles.commentContainer}>
<div className={`${styles.commentContainer} talk-stream-comment-container`}>
<div className={styles.header}>
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
@@ -19,6 +19,7 @@ import cn from 'classnames';
import {getTopLevelParent, attachCommentToParent} from '../graphql/utils';
import AllCommentsPane from './AllCommentsPane';
import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
import styles from './Stream.css';
@@ -101,7 +102,7 @@ class Stream extends React.Component {
editName
} = this.props;
const {keepCommentBox} = this.state;
const open = asset.closedAt === null;
const open = !asset.isClosed;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
let highlightedComment = comment && getTopLevelParent(comment);
@@ -141,6 +142,7 @@ class Stream extends React.Component {
return (
<div id="stream" className={styles.root}>
<AutomaticAssetClosure assetId={asset.id} closedAt={asset.closedAt}/>
{comment &&
<Button
cStyle="darkGrey"
@@ -290,6 +292,7 @@ class Stream extends React.Component {
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={this.props.emit}
/>
</TabPane>
</TabContent>
@@ -1,10 +1,10 @@
import React from 'react';
import ClickOutside from 'coral-framework/components/ClickOutside';
import styles from './Toggleable.css';
import classnames from 'classnames';
import cn from 'classnames';
const upArrow = <span className={classnames(styles.chevron, styles.up)}></span>;
const downArrow = <span className={classnames(styles.chevron, styles.down)}></span>;
const upArrow = <span className={cn(styles.chevron, styles.up)}></span>;
const downArrow = <span className={cn(styles.chevron, styles.down)}></span>;
export default class Toggleable extends React.Component {
constructor(props) {
@@ -23,11 +23,11 @@ export default class Toggleable extends React.Component {
}
render() {
const {children} = this.props;
const {children, className, ...rest} = this.props;
const {isOpen} = this.state;
return (
<ClickOutside onClickOutside={this.close}>
<span className={styles.Toggleable}>
<span {...rest} className={cn(className, styles.Toggleable)} >
<button className={styles.toggler} onClick={this.toggle}>{isOpen ? upArrow : downArrow}</button>
{isOpen ? children : null}
</span>
@@ -45,7 +45,7 @@ export class TopRightMenu extends React.Component {
}
};
return (
<Toggleable key={this.state.timesReset}>
<Toggleable key={this.state.timesReset} className="talk-stream-comment-chevron">
<div style={{position: 'absolute', right: 0, zIndex: 1}}>
<IgnoreUserWizard
user={comment.user}
@@ -0,0 +1,81 @@
import React from 'react';
import PropTypes from 'prop-types';
import {gql} from 'react-apollo';
const FRAGMENT = gql`
fragment CoralEmbedStream_AutomaticAssetClosure_Fragment on Asset {
id
isClosed
}
`;
function getFragmentId(assetId) {
return `Asset_${assetId}`;
}
/**
* AutomaticAssetClosure updates the graphql state of the provide asset
* to `isClosed=true` when passed `closedAt`.
*/
class AutomaticAssetClosure extends React.Component {
static contextTypes = {
client: PropTypes.object.isRequired,
};
timer = null;
componentWillMount() {
this.setupTimer(this.props.assetId, this.props.closedAt);
}
componentWillReceiveProps(next) {
if (
this.props.assetId !== next.assetId ||
this.props.closedAt !== next.closedAt
) {
this.setupTimer(next.assetId, next.closedAt);
}
}
closeAsset(assetId) {
this.context.client.writeFragment({
fragment: FRAGMENT,
id: getFragmentId(assetId),
data: {
isClosed: true,
},
});
}
setupTimer(assetId, closedAt) {
clearTimeout(this.timer);
this.timer = null;
if (assetId && closedAt) {
const asset = this.context.client.readFragment({
fragment: FRAGMENT,
id: getFragmentId(assetId),
});
if (!asset.isClosed && closedAt) {
const diff = (new Date(closedAt) - new Date());
if (diff >= 0) {
this.timer = setTimeout(() => this.closeAsset(assetId), diff);
} else {
this.closeAsset(assetId);
}
}
}
}
render() {
return null;
}
}
AutomaticAssetClosure.PropTypes = {
assetId: PropTypes.string,
closedAt: PropTypes.string,
};
export default AutomaticAssetClosure;
@@ -14,7 +14,7 @@ import {editName} from 'coral-framework/actions/user';
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream';
import Stream from '../components/Stream';
import Comment from './Comment';
import {withFragments} from 'coral-framework/hocs';
import {withFragments, withEmit} from 'coral-framework/hocs';
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
import {Spinner} from 'coral-ui';
import {
@@ -251,6 +251,7 @@ const fragments = {
title
url
closedAt
isClosed
created_at
settings {
moderation
@@ -325,6 +326,7 @@ const mapDispatchToProps = (dispatch) =>
export default compose(
withFragments(fragments),
withEmit,
connect(mapStateToProps, mapDispatchToProps),
withPostComment,
withPostFlag,
@@ -73,6 +73,11 @@ const extension = {
created_at
status
replyCount
asset {
id
title
url
}
tags {
tag {
name
@@ -190,6 +195,15 @@ const extension = {
}
return insertCommentIntoEmbedQuery(prev, comment);
},
CoralEmbedStream_Profile: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
return update(prev, {
me: {
comments: {
nodes: {$unshift: [comment]},
},
},
});
},
}
}),
EditComment: () => ({
+74
View File
@@ -0,0 +1,74 @@
const DEFAULT_STYLE = {
position: 'fixed',
cursor: 'default',
userSelect: 'none',
backgroundColor: '#323232',
zIndex: 3,
willChange: 'transform, opacity',
transition: 'transform .35s cubic-bezier(.55,0,.1,1), opacity .35s',
pointerEvents: 'none',
padding: '12px 18px',
color: '#fff',
borderRadius: '3px 3px 0 0',
textAlign: 'center',
maxWidth: '400px',
left: '50%',
opacity: 0,
transform: 'translate(-50%, 20px)',
bottom: 0,
boxSizing: 'border-box',
fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif'
};
export default class Snackbar {
constructor(customStyle = {}) {
this.timeout = null;
this.el = document.createElement('div');
this.el.id = 'coral-notif';
// Apply custom styles to the snackbar.
const style = Object.assign({}, DEFAULT_STYLE, customStyle);
for (let key in style) {
this.el.style[key] = style[key];
}
}
clear() {
this.el.style.opacity = 0;
}
alert(message) {
const [type, text] = message.split('|');
this.el.style.transform = 'translate(-50%, 20px)';
this.el.style.opacity = 0;
this.el.className = `coral-notif-${type}`;
this.el.textContent = text;
if (this.timeout) {
clearTimeout(this.timeout);
}
this.timeout = setTimeout(() => {
this.el.style.transform = 'translate(-50%, 0)';
this.el.style.opacity = 1;
this.timeout = setTimeout(() => {
this.el.style.opacity = 0;
}, 7000);
}, 0);
}
attach(el, pym) {
el.appendChild(this.el);
// Attach the clear clear notification event to the clear method.
pym.onMessage('coral-clear-notification', this.clear.bind(this));
// Attach the alert to the alert method.
pym.onMessage('coral-alert', this.alert.bind(this));
}
remove() {
this.el.remove();
}
}
+163
View File
@@ -0,0 +1,163 @@
import queryString from 'query-string';
import pym from 'pym.js';
import EventEmitter from 'eventemitter2';
import {buildUrl} from 'coral-framework/utils/url';
import Snackbar from './Snackbar';
const NOTIFICATION_OFFSET = 200;
// Build the URL to load in the pym iframe.
function buildStreamIframeUrl(talkBaseUrl, query) {
let url = [
talkBaseUrl,
talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/'
'embed/stream?'
].join('');
url += queryString.stringify(query);
return url;
}
// Get dimensions of viewport.
function viewportDimensions() {
let e = window, a = 'inner';
if (!('innerWidth' in window)) {
a = 'client';
e = document.documentElement || document.body;
}
return {
width: e[`${a}Width`],
height: e[`${a}Height`]
};
}
export default class Stream {
constructor(el, talkBaseUrl, query, opts) {
// Create and save the options.
this.opts = opts;
this.query = query;
this.emitter = new EventEmitter({wildcard: true});
this.pym = new pym.Parent(el.id, buildStreamIframeUrl(talkBaseUrl, query), {
title: opts.title,
id: `${el.id}_iframe`,
name: `${el.id}_iframe`
});
this.snackBar = new Snackbar(opts.snackBarStyles || {});
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
this.pym.el.firstChild.style.width = '1px';
this.pym.el.firstChild.style.minWidth = '100%';
// Resize parent iframe height when child height changes
let cachedHeight;
this.pym.onMessage('height', (height) => {
if (height !== cachedHeight) {
this.pym.el.firstChild.style.height = `${height}px`;
cachedHeight = height;
}
});
// Attach to the events emitted by the pym parent.
if (opts.events) {
opts.events(this.emitter);
}
this.pym.onMessage('getConfig', () => {
this.pym.sendMessage('config', JSON.stringify(opts));
});
// If the auth changes, and someone is listening for it, then re-emit it.
if (opts.onAuthChanged) {
this.pym.onMessage('coral-auth-changed', (message) => {
opts.onAuthChanged(message ? JSON.parse(message) : null);
});
}
// Attach the snackbar to the pym parent and to the body of the page.
this.snackBar.attach(window.document.body, this.pym);
// Remove the permalink comment id from the hash.
this.pym.onMessage('coral-view-all-comments', () => {
const search = queryString.stringify({
...queryString.parse(location.search),
commentId: undefined,
});
// Remove the commentId url param.
const url = buildUrl({...location, search});
// Change the url.
window.history.replaceState({}, document.title, url);
});
// Remove the permalink comment id from the hash.
this.pym.onMessage('coral-view-comment', (id) => {
const search = queryString.stringify({
...queryString.parse(location.search),
commentId: id,
});
// Remove the commentId url param.
const url = buildUrl({...location, search});
// Change the url.
window.history.replaceState({}, document.title, url);
});
// Helps child show notifications at the right scrollTop.
this.pym.onMessage('getPosition', () => {
const {height} = viewportDimensions();
let position = height + document.body.scrollTop;
if (position > NOTIFICATION_OFFSET) {
position = position - NOTIFICATION_OFFSET;
}
this.pym.sendMessage('position', position);
});
// When end-user clicks link in iframe, open it in parent context
this.pym.onMessage('navigate', (url) => {
window.open(url, '_blank').focus();
});
// Pass events from iframe to the event emitter.
this.pym.onMessage('event', (raw) => {
const {eventName, value} = JSON.parse(raw);
this.emitter.emit(eventName, value);
});
// If the user clicks outside the embed, then tell the embed.
document.addEventListener('click', this.handleClick.bind(this), true);
}
login(token) {
this.pym.sendMessage('login', token);
}
logout() {
this.pym.sendMessage('logout');
}
remove() {
// Remove the event listeners.
document.removeEventListener('click', this.handleClick.bind(this));
this.emitter.removeAllListeners();
// Remove the snackbar.
this.snackBar.remove();
// Remove the pym parent.
this.pym.remove();
}
handleClick() {
this.pym.sendMessage('click');
}
}
+21
View File
@@ -0,0 +1,21 @@
export default class StreamInterface {
constructor(stream) {
this._stream = stream;
}
on(eventName, callback) {
return this._stream.emitter.on(eventName, callback);
}
login(token) {
return this._stream.login(token);
}
logout() {
return this._stream.logout();
}
remove() {
return this._stream.remove();
}
}
+20 -224
View File
@@ -1,196 +1,10 @@
import pym from 'pym.js';
import URLSearchParams from 'url-search-params';
import {buildUrl} from 'coral-framework/utils';
import queryString from 'query-string';
import EventEmitter from 'eventemitter2';
// TODO: Styles should live in a separate file
const snackbarStyles = {
position: 'fixed',
cursor: 'default',
userSelect: 'none',
backgroundColor: '#323232',
zIndex: 3,
willChange: 'transform, opacity',
transition: 'transform .35s cubic-bezier(.55,0,.1,1), opacity .35s',
pointerEvents: 'none',
padding: '12px 18px',
color: '#fff',
borderRadius: '3px 3px 0 0',
textAlign: 'center',
maxWidth: '400px',
left: '50%',
opacity: 0,
transform: 'translate(-50%, 20px)',
bottom: 0,
boxSizing: 'border-box',
fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif'
};
import Stream from './Stream';
import StreamInterface from './StreamInterface';
// This function should return value of window.Coral
const Coral = {};
const Talk = (Coral.Talk = {});
let notificationTimeout = null;
// build the URL to load in the pym iframe
function buildStreamIframeUrl(talkBaseUrl, query) {
let url = [
talkBaseUrl,
talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/'
'embed/stream?'
].join('');
url += queryString.stringify(query);
return url;
}
// Set up postMessage listeners/handlers on the pymParent
// e.g. to resize the iframe, and navigate the host page
function configurePymParent(pymParent, eventEmitter, opts) {
let notificationOffset = 200;
let cachedHeight;
const snackbar = document.createElement('div');
// Sends config to pymChild
function sendConfig(config) {
pymParent.sendMessage('config', JSON.stringify(config));
}
if (opts.events) {
opts.events(eventEmitter);
}
pymParent.onMessage('coral-auth-changed', function(message) {
if (opts.onAuthChanged) {
opts.onAuthChanged(message ? JSON.parse(message) : null);
}
});
// Sends config to the child
pymParent.onMessage('getConfig', function() {
sendConfig(opts || {});
});
snackbar.id = 'coral-notif';
for (let key in snackbarStyles) {
snackbar.style[key] = snackbarStyles[key];
}
window.document.body.appendChild(snackbar);
// Notify embed that there was a click outside.
document.addEventListener('click', () => {
pymParent.sendMessage('click');
}, true);
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
pymParent.el.firstChild.style.width = '1px';
pymParent.el.firstChild.style.minWidth = '100%';
// Resize parent iframe height when child height changes
pymParent.onMessage('height', function(height) {
if (height !== cachedHeight) {
pymParent.el.firstChild.style.height = `${height}px`;
cachedHeight = height;
}
});
pymParent.onMessage('coral-clear-notification', function() {
snackbar.style.opacity = 0;
});
// remove the permalink comment id from the hash
pymParent.onMessage('coral-view-all-comments', function() {
const search = queryString.stringify({
...queryString.parse(location.search),
commentId: undefined,
});
// remove the commentId url param
const url = buildUrl({...location, search});
window.history.replaceState(
{},
document.title,
url,
);
});
// remove the permalink comment id from the hash
pymParent.onMessage('coral-view-comment', function(id) {
const search = queryString.stringify({
...queryString.parse(location.search),
commentId: id,
});
// remove the commentId url param
const url = buildUrl({...location, search});
window.history.replaceState(
{},
document.title,
url,
);
});
pymParent.onMessage('coral-alert', function(message) {
const [type, text] = message.split('|');
snackbar.style.transform = 'translate(-50%, 20px)';
snackbar.style.opacity = 0;
snackbar.className = `coral-notif-${type}`;
snackbar.textContent = text;
clearTimeout(notificationTimeout);
notificationTimeout = setTimeout(() => {
snackbar.style.transform = 'translate(-50%, 0)';
snackbar.style.opacity = 1;
notificationTimeout = setTimeout(() => {
snackbar.style.opacity = 0;
}, 7000);
}, 0);
});
// Helps child show notifications at the right scrollTop
pymParent.onMessage('getPosition', function() {
let position = viewport().height + document.body.scrollTop;
if (position > notificationOffset) {
position = position - notificationOffset;
}
pymParent.sendMessage('position', position);
});
// When end-user clicks link in iframe, open it in parent context
pymParent.onMessage('navigate', function(url) {
window.open(url, '_blank').focus();
});
// Pass events from iframe to the event emitter
pymParent.onMessage('event', (raw) => {
const {eventName, value} = JSON.parse(raw);
eventEmitter.emit(eventName, value);
});
// get dimensions of viewport
const viewport = () => {
let e = window, a = 'inner';
if (!('innerWidth' in window)) {
a = 'client';
e = document.documentElement || document.body;
}
return {
width: e[`${a}Width`],
height: e[`${a}Height`]
};
};
}
/**
* Render a Talk stream
@@ -219,17 +33,14 @@ function configurePymParent(pymParent, eventEmitter, opts) {
* });
* ```
*/
Talk.render = function(el, opts) {
Talk.render = (el, opts) => {
if (!el) {
throw new Error(
'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'
);
throw new Error('Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.');
}
if (typeof el !== 'object') {
throw new Error(
`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`
);
throw new Error(`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`);
}
opts = opts || {};
// TODO: infer this URL without explicit user input (if possible, may have to be added at build/render time of this script)
@@ -245,22 +56,27 @@ Talk.render = function(el, opts) {
}
// Compose the query to send down to the Talk API so it knows what to load.
let query = {};
let urlParams = new URLSearchParams(window.location.search);
const query = {};
// Parse the url parameters to extract some of the information.
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('commentId')) {
query.comment_id = urlParams.get('commentId');
}
// Extract the asset id from the options.
if (opts.asset_id) {
query.asset_id = opts.asset_id;
}
// Extract the asset url.
if (opts.asset_url) {
query.asset_url = opts.asset_url;
}
else {
} else if (!opts.asset_id) {
// 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.
try {
query.asset_url = document.querySelector('link[rel="canonical"]').href;
} catch (e) {
@@ -276,31 +92,11 @@ Talk.render = function(el, opts) {
}
}
const pymParent = new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), {
title: opts.title,
id: `${el.id}_iframe`,
name: `${el.id}_iframe`
});
// Create the new Stream.
const stream = new Stream(el, opts.talk, query, opts);
const eventEmitter = new EventEmitter({wildcard: true});
configurePymParent(
pymParent,
eventEmitter,
opts
);
return {
on(eventName, callback) {
eventEmitter.on(eventName, callback);
},
login(token) {
pymParent.sendMessage('login', token);
},
logout() {
pymParent.sendMessage('logout');
}
};
// Return the public interface for the stream.
return new StreamInterface(stream);
};
export default Coral;
+10 -3
View File
@@ -3,6 +3,7 @@ import uniq from 'lodash/uniq';
import pick from 'lodash/pick';
import merge from 'lodash/merge';
import flattenDeep from 'lodash/flattenDeep';
import isEmpty from 'lodash/isEmpty';
import flatten from 'lodash/flatten';
import {loadTranslations} from 'coral-framework/services/i18n';
import {injectReducers} from 'coral-framework/services/store';
@@ -10,7 +11,7 @@ import camelize from './camelize';
import plugins from 'pluginsConfig';
export function getSlotComponents(slot, reduxState, props = {}) {
const pluginConfig = reduxState.config.pluginConfig || {};
const pluginConfig = reduxState.config.plugin_config || {};
return flatten(plugins
// Filter out components that have slots and have been disabled in `plugin_config`
@@ -39,7 +40,7 @@ export function isSlotEmpty(slot, reduxState, props) {
* Returns React Elements for given slot.
*/
export function getSlotElements(slot, reduxState, props = {}) {
const pluginConfig = reduxState.config.pluginConfig || {};
const pluginConfig = reduxState.config.plugin_config || {};
return getSlotComponents(slot, reduxState, props)
.map((component, i) => React.createElement(component, {key: i, ...props, config: pluginConfig}));
}
@@ -64,7 +65,13 @@ export function getSlotFragments(slot, part) {
export function getGraphQLExtensions() {
return plugins
.map((o) => pick(o.module, ['mutations', 'queries', 'fragments']))
.filter((o) => o);
.filter((o) => !isEmpty(o));
}
export function getModQueueConfigs() {
return merge(...plugins
.map((o) => o.module.modQueues)
.filter((o) => o));
}
function getTranslations() {
+1 -8
View File
@@ -169,14 +169,7 @@ export function insertCommentsSorted(nodes, comments, sortOrder = 'CHRONOLOGICAL
export const isTagged = (tags, which) => tags.some((t) => t.tag.name === which);
export function buildUrl({protocol, hostname, port, pathname, search, hash} = window.location) {
if (search && search[0] !== '?') {
search = `?${search}`;
} else if (search === '?') {
search = '';
}
return `${protocol}//${hostname}${port ? `:${port}` : ''}${pathname}${search}${hash}`;
}
export * from './url';
/**
* getSlotFragmentSpreads will return a string in the
+8
View File
@@ -0,0 +1,8 @@
export function buildUrl({protocol, hostname, port, pathname, search, hash} = window.location) {
if (search && search[0] !== '?') {
search = `?${search}`;
} else if (search === '?') {
search = '';
}
return `${protocol}//${hostname}${port ? `:${port}` : ''}${pathname}${search}${hash}`;
}
@@ -1,7 +1,8 @@
import {connect} from 'react-redux';
import {compose, graphql, gql} from 'react-apollo';
import {compose, gql} from 'react-apollo';
import React, {Component} from 'react';
import {bindActionCreators} from 'redux';
import {withQuery} from 'coral-framework/hocs';
import {withStopIgnoringUser} from 'coral-framework/graphql/mutations';
@@ -11,18 +12,12 @@ import IgnoredUsers from '../components/IgnoredUsers';
import {Spinner} from 'coral-ui';
import CommentHistory from 'talk-plugin-history/CommentHistory';
import {showSignInDialog, checkLogin} from 'coral-framework/actions/auth';
import {insertCommentsSorted} from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
import t from 'coral-framework/services/i18n';
class ProfileContainer extends Component {
constructor() {
super();
this.state = {
activeTab: 0
};
}
componentWillReceiveProps(nextProps) {
if (!this.props.auth.loggedIn && nextProps.auth.loggedIn) {
@@ -31,21 +26,40 @@ class ProfileContainer extends Component {
}
}
handleTabChange = (tab) => {
this.setState({
activeTab: tab
loadMore = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit: 5,
cursor: this.props.root.me.comments.endCursor,
},
updateQuery: (previous, {fetchMoreResult:{comments}}) => {
const updated = update(previous, {
me: {
comments: {
nodes: {
$apply: (nodes) => insertCommentsSorted(nodes, comments.nodes, 'REVERSE_CHRONOLOGICAL'),
},
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
},
}
});
return updated;
},
});
};
render() {
const {auth, asset, data, showSignInDialog, stopIgnoringUser} = this.props;
const {me} = this.props.data;
const {auth, asset, showSignInDialog, stopIgnoringUser} = this.props;
const {me} = this.props.root;
const loading = [1, 2, 4].indexOf(this.props.data.networkStatus) >= 0;
if (!auth.loggedIn) {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
}
if (!me || data.loading) {
if (loading) {
return <Spinner />;
}
@@ -73,14 +87,40 @@ class ProfileContainer extends Component {
<h3>{t('framework.my_comments')}</h3>
{me.comments.nodes.length
? <CommentHistory comments={me.comments.nodes} asset={asset} link={link} />
? <CommentHistory comments={me.comments} asset={asset} link={link} loadMore={this.loadMore}/>
: <p>{t('user_no_comment')}</p>}
</div>
);
}
}
const withQuery = graphql(
const CommentFragment = gql`
fragment TalkSettings_CommentConnectionFragment on CommentConnection {
nodes {
id
body
asset {
id
title
url
}
created_at
}
endCursor
hasNextPage
}
`;
const LOAD_MORE_QUERY = gql`
query TalkSettings_LoadMoreComments($limit: Int, $cursor: Date) {
comments(query: {limit: $limit, cursor: $cursor}) {
...TalkSettings_CommentConnectionFragment
}
}
${CommentFragment}
`;
const withProfileQuery = withQuery(
gql`
query CoralEmbedStream_Profile {
me {
@@ -89,21 +129,13 @@ const withQuery = graphql(
id,
username,
}
comments {
nodes {
id
body
asset {
id
title
url
}
created_at
}
comments(query: {limit: 10}) {
...TalkSettings_CommentConnectionFragment
}
}
}`
);
}
${CommentFragment}
`);
const mapStateToProps = (state) => ({
user: state.user.toJS(),
@@ -117,5 +149,5 @@ const mapDispatchToProps = (dispatch) =>
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withStopIgnoringUser,
withQuery
withProfileQuery
)(ProfileContainer);
+4 -3
View File
@@ -27,9 +27,10 @@
}
.icon {
margin-right: 13px;
margin-right: 5px;
font-size: 18px;
vertical-align: middle;
margin-top: -3px;
}
.type--black {
@@ -143,7 +144,7 @@
border-radius: 3px;
text-transform: capitalize;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.03), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.09);
width: 128px;
width: 129px;
&:hover {
box-shadow: none;
@@ -166,7 +167,7 @@
border-radius: 3px;
text-transform: capitalize;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.03), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.09);
width: 128px;
width: 129px;
&:hover {
color: white;
+1 -1
View File
@@ -3,7 +3,7 @@
min-width: 550px;
position: fixed;
top: 0;
right: -17px;
right: 0px;
bottom: 0;
background-color: white;
transition: transform 500ms ease-in-out;
+42 -15
View File
@@ -1,25 +1,52 @@
import React, {PropTypes} from 'react';
import Comment from './Comment';
import styles from './CommentHistory.css';
import LoadMore from './LoadMore';
import {forEachError} from 'plugin-api/beta/client/utils';
const CommentHistory = (props) => {
return (
<div className={`${styles.header} commentHistory`}>
<div className="commentHistory__list">
{props.comments.map((comment, i) => {
return <Comment
key={i}
comment={comment}
link={props.link}
asset={comment.asset} />;
})}
class CommentHistory extends React.Component {
state = {
loadingState: '',
};
loadMore = () => {
this.setState({loadingState: 'loading'});
this.props.loadMore()
.then(() => {
this.setState({loadingState: 'success'});
})
.catch((error) => {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
});
}
render() {
const {link, comments} = this.props;
return (
<div className={`${styles.header} commentHistory`}>
<div className="commentHistory__list">
{comments.nodes.map((comment, i) => {
return <Comment
key={i}
comment={comment}
link={link}
asset={comment.asset} />;
})}
</div>
{comments.hasNextPage &&
<LoadMore
loadMore={this.loadMore}
loadingState={this.state.loadingState}
/>
}
</div>
</div>
);
};
);
}
}
CommentHistory.propTypes = {
comments: PropTypes.array.isRequired
comments: PropTypes.object.isRequired
};
export default CommentHistory;
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Button} from 'coral-ui';
import t from 'coral-framework/services/i18n';
import cn from 'classnames';
class LoadMore extends React.Component {
render () {
const {loadingState, loadMore} = this.props;
const disabled = loadingState === 'loading';
return (
<div className='talk-load-more'>
<Button
onClick={loadMore}
className={cn('talk-load-more-button', {[`talk-load-more-button-${loadingState}`]: loadingState})}
disabled={disabled}
>
{t('framework.view_more_comments')}
</Button>
</div>
);
}
}
LoadMore.propTypes = {
loadMore: PropTypes.func.isRequired,
loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']),
};
export default LoadMore;
+49 -11
View File
@@ -7,6 +7,8 @@
// entrypoint for the entire applications configuration.
require('env-rewrite').rewrite();
const uniq = require('lodash/uniq');
//==============================================================================
// CONFIG INITIALIZATION
//==============================================================================
@@ -31,15 +33,33 @@ const CONFIG = {
// token.
JWT_COOKIE_NAME: process.env.TALK_JWT_COOKIE_NAME || 'authorization',
// JWT_SIGNING_COOKIE_NAME will be the cookie set when cookies are issued.
// This defaults to the TALK_JWT_COOKIE_NAME value.
JWT_SIGNING_COOKIE_NAME: process.env.TALK_JWT_SIGNING_COOKIE_NAME || process.env.TALK_JWT_COOKIE_NAME || 'authorization',
// JWT_COOKIE_NAMES declares the many cookie names used for verification.
JWT_COOKIE_NAMES: process.env.TALK_JWT_COOKIE_NAMES || null,
// JWT_CLEAR_COOKIE_LOGOUT specifies whether the named cookie should be
// cleared when the user is logged out.
JWT_CLEAR_COOKIE_LOGOUT: process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT ? process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT !== 'FALSE' : true,
// JWT_DISABLE_AUDIENCE when TRUE will disable the audience claim (aud) from tokens.
JWT_DISABLE_AUDIENCE: process.env.TALK_JWT_DISABLE_AUDIENCE === 'TRUE',
// JWT_AUDIENCE is the value for the audience claim for the tokens that will be
// verified when decoding. If `JWT_AUDIENCE` is not in the environment, then it
// will default to `talk`.
JWT_AUDIENCE: process.env.TALK_JWT_AUDIENCE || 'talk',
// JWT_DISABLE_ISSUER when TRUE will disable the issuer claim (iss) from tokens.
JWT_DISABLE_ISSUER: process.env.TALK_JWT_DISABLE_ISSUER === 'TRUE',
// JWT_USER_ID_CLAIM is the claim which stores the user's id. This may be a deep
// object delimited using dot notation. Example `user.id` would store it like:
// {user: {id}} on the claims object. (Default `sub`)
JWT_USER_ID_CLAIM: process.env.TALK_JWT_USER_ID_CLAIM || 'sub',
// JWT_ISSUER is the value for the issuer for the tokens that will be verified
// when decoding. If `JWT_ISSUER` is not in the environment, then it will try
// `TALK_ROOT_URL`, otherwise, it will be undefined.
@@ -130,22 +150,40 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.ROOT_URL) {
if (CONFIG.JWT_SECRETS) {
CONFIG.JWT_SECRETS = JSON.parse(CONFIG.JWT_SECRETS);
}
if (process.env.NODE_ENV === 'test' && !CONFIG.JWT_SECRET) {
CONFIG.JWT_SECRET = 'keyboard cat';
} else if (!CONFIG.JWT_SECRET) {
throw new Error(
'TALK_JWT_SECRET must be provided in the environment to sign/verify tokens'
);
if (process.env.NODE_ENV === 'test') {
if (!CONFIG.JWT_ALG.startsWith('HS')) {
throw new Error('Providing a asymmetric signing/verfying algorithm without a corresponding secret is not permitted');
}
CONFIG.JWT_SECRET = 'keyboard cat';
} else {
throw new Error(
'TALK_JWT_SECRET must be provided in the environment to sign/verify tokens'
);
}
}
// If this is not employing a HMAC based signing method, then we need to turn
// the secret into a buffer.
if (!CONFIG.JWT_ALG.startsWith('HS')) {
CONFIG.JWT_SECRET = Buffer.from(CONFIG.JWT_SECRET);
// Disable the audience claim if requested.
if (CONFIG.JWT_DISABLE_AUDIENCE) {
CONFIG.JWT_AUDIENCE = undefined;
}
// Disable the issuer claim if requested.
if (CONFIG.JWT_DISABLE_ISSUER) {
CONFIG.JWT_ISSUER = undefined;
}
// Parse and handle cookie names.
if (CONFIG.JWT_COOKIE_NAMES) {
CONFIG.JWT_COOKIE_NAMES = CONFIG.JWT_COOKIE_NAMES.split(',');
} else {
CONFIG.JWT_COOKIE_NAMES = [];
}
// Add in the default cookie names and strip duplicates.
CONFIG.JWT_COOKIE_NAMES = uniq(CONFIG.JWT_COOKIE_NAMES.concat([CONFIG.JWT_COOKIE_NAME, CONFIG.JWT_SIGNING_COOKIE_NAME]));
//------------------------------------------------------------------------------
// External database url's
//------------------------------------------------------------------------------
+49 -5
View File
@@ -77,16 +77,60 @@ The following are configuration shared with every type of secret used.
tokens. (Default `process.env.TALK_ROOT_URL`)
- `TALK_JWT_AUDIENCE` (_optional_) - the audience (`aud`) claim for login JWT
tokens. (Default `talk`)
- `TALK_JWT_COOKIE_NAME` (_optional_) - the name of the cookie to extract the
JWT from (Default `authorization`)
- `TALK_JWT_CLEAR_COOKIE_LOGOUT` (_optional_) - when `FALSE`, Talk will not
clear the cookie with name `TALK_JWT_COOKIE_NAME` when logging out (Default
`TRUE`)
**You must also specify secrets as either the `TALK_JWT_SECRET` or the `TALK_JWT_SECRETS`
variable. Refer to the [Secrets Documentation]({{ "/docs/running/secrets/" | absolute_url }})
on the contents of those variables.**
#### Advanced
These are advanced settings for fine tuning the auth integration, and
is not needed in most situations.
- `TALK_JWT_COOKIE_NAME` (_optional_) - the default cookie name to check for a
valid JWT token to use for verifying a user. (Default `authorization`)
- `TALK_JWT_SIGNING_COOKIE_NAME` (_optional_) - the default cookie name that is
use to set a cookie containing a JWT that was issued by Talk.
(Default `process.env.TALK_JWT_COOKIE_NAME`)
- `TALK_JWT_COOKIE_NAMES` (_optional_) - the different cookie names to check for
a JWT token in, seperated by `,`. By default, we always use the
`process.env.TALK_JWT_COOKIE_NAME` and `process.env.TALK_JWT_SIGNING_COOKIE_NAME`
for this value. Any additional cookie names specified here will be appended to
the list of cookie names to inspect.
- `TALK_JWT_CLEAR_COOKIE_LOGOUT` (_optional_) - when `FALSE`, Talk will not
clear the cookie with name `TALK_JWT_COOKIE_NAME` when logging out (Default
`TRUE`)
- `TALK_JWT_DISABLE_AUDIENCE` (_optional_) - when `TRUE`, Talk will not verify or sign JWT's
with an audience (`aud`) claim, even if the `TALK_JWT_AUDIENCE` config is set. (Default `FALSE`)
- `TALK_JWT_DISABLE_ISSUER` (_optional_) - when `TRUE`, Talk will not verify or sign JWT's
with an issuer (`iss`) claim, even if the `TALK_JWT_ISSUER` config is set. (Default `FALSE`)
- `TALK_JWT_USER_ID_CLAIM` (_optional_) - specify the claim using dot notation for where the
user id should be stored/read to/from. Example `user.id` would store it like: `{user: {id}}`
on the claims object. (Default `sub`)
When integrating with an external authentication system, the following JWT claims
will be used:
```js
{
"jti": "<the unique token identifier>", // *required* unique id used for blacklisting
"aud": TALK_JWT_AUDIENCE, // *optional* if TALK_JWT_DISABLE_AUDIENCE === 'TRUE', *required* otherwise
"iss": TALK_JWT_ISSUER, // *optional* if TALK_JWT_DISABLE_ISSUER === 'TRUE', *required* otherwise
[TALK_JWT_USER_ID_CLAIM]: "<the user id>", // *required* the id of the user
// Note, if TALK_JWT_USER_ID_CLAIM contains '.', it will be used to deliniate an object, for example
// `user.id` would store it like: `{user: {id}}`
}
```
When our passport middleware checks for JWT tokens, it searches in the following
order:
1. Custom cookies named from the list in `TALK_JWT_COOKIE_NAMES`.
2. Default cookies named `TALK_JWT_COOKIE_NAME` then `TALK_JWT_SIGNING_COOKIE_NAME`.
3. Query parameter `?access_token={TOKEN}`.
4. Header: `Authorization: Bearer {TOKEN}`.
### Email
- `TALK_SMTP_EMAIL` (*required for email*) - the address to send emails from
+5
View File
@@ -60,6 +60,11 @@ must have their newlines replaced with `\\n`, this is to ensure that the
newlines are preserved after JSON decoding. Not doing so will result in parsing
errors.
To assist with this process, we have developed a tool that can generate new
certificates that match our required format: [coralcert](https://github.com/coralproject/coralcert).
This tool can generate RSA and ECDSA certificates, check it's [README](https://github.com/coralproject/coralcert)
for more details.
## Authentication Types
Talk also supports two methods of providing authenticationd details.
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Development Tooling
permalink: /docs/development/tools
permalink: /docs/development/tools/
---
## Debugging
+8 -5
View File
@@ -2,18 +2,21 @@ const errors = require('../../errors');
const {Error: {ValidationError}} = require('mongoose');
/**
* Wraps up a promise to return an object with the resolution of the promise
* Wraps up a promise or value to return an object with the resolution of the promise
* keyed at `key` or an error caught at `errors`.
*/
const wrapResponse = (key) => (promise) => {
return promise.then((value) => {
const wrapResponse = (key) => async (promise) => {
try {
let value = await promise;
let res = {};
if (key) {
res[key] = value;
}
return res;
}).catch((err) => {
} catch (err) {
if (err instanceof errors.APIError) {
return {
errors: [err]
@@ -25,7 +28,7 @@ const wrapResponse = (key) => (promise) => {
}
throw err;
});
}
};
module.exports = wrapResponse;
+2 -2
View File
@@ -29,12 +29,12 @@ const User = {
return null;
},
comments({id}, _, {loaders: {Comments}, user}) {
comments({id}, {query}, {loaders: {Comments}, user}) {
// If the user is not an admin, only return comment list for the owner of
// the comments.
if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === id)) {
return Comments.getByQuery({author_id: id, sort: 'REVERSE_CHRONOLOGICAL'});
return Comments.getByQuery(Object.assign({}, query, {author_id: id}));
}
return null;
+3
View File
@@ -596,6 +596,9 @@ type Asset {
# The date that the asset was closed at.
closedAt: Date
# True if asset is closed.
isClosed: Boolean!
# Summary of all Actions against all entities associated with the Asset.
# (likes, flags, etc.). Requires the `ADMIN` role.
action_summaries: [AssetActionSummary!]
+2 -2
View File
@@ -61,7 +61,7 @@ const AssetSchema = new Schema({
timestamps: {
createdAt: 'created_at',
updatedAt: 'updated_at'
}
},
});
AssetSchema.index({
@@ -79,7 +79,7 @@ AssetSchema.index({
* Returns true if the asset is closed, false else.
*/
AssetSchema.virtual('isClosed').get(function() {
return this.closedAt && this.closedAt.getTime() <= new Date().getTime();
return Boolean(this.closedAt && this.closedAt.getTime() <= new Date().getTime());
});
const Asset = mongoose.model('Asset', AssetSchema);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "3.0.0",
"version": "3.1.0",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"scripts": {
@@ -93,7 +93,7 @@
"graphql-tools": "^0.10.1",
"helmet": "^3.5.0",
"immutability-helper": "^2.2.0",
"inquirer": "^3.0.6",
"inquirer": "^3.2.1",
"joi": "^10.4.1",
"jsonwebtoken": "^7.3.0",
"jwt-decode": "^2.2.0",
+1
View File
@@ -3,3 +3,4 @@ export {default as withTags} from './withTags';
export {default as withFragments} from 'coral-framework/hocs/withFragments';
export {default as excludeIf} from 'coral-framework/hocs/excludeIf';
export {default as connect} from 'coral-framework/hocs/connect';
export {default as withEmit} from 'coral-framework/hocs/withEmit';
@@ -271,6 +271,7 @@ export default (reaction) => (WrappedComponent) => {
alreadyReacted={alreadyReacted}
postReaction={this.postReaction}
deleteReaction={this.deleteReaction}
config={this.props.config}
/>;
}
}
+3 -2
View File
@@ -68,16 +68,17 @@ export default (tag) => (WrappedComponent) => {
}
render() {
const {comment} = this.props;
const {comment, user, config} = this.props;
const alreadyTagged = isTagged(comment.tags, TAG);
return <WrappedComponent
user={this.props.user}
user={user}
comment={comment}
alreadyTagged={alreadyTagged}
postTag={this.postTag}
deleteTag={this.deleteTag}
config={config}
/>;
}
}
@@ -6,7 +6,7 @@ import {showSignInDialog} from 'coral-framework/actions/auth';
import t from 'coral-framework/services/i18n';
const SignInButton = ({loggedIn, showSignInDialog}) => (
<div>
<div className="talk-stream-auth-sign-in-button">
{!loggedIn
? <Button id="coralSignInButton" onClick={showSignInDialog} full>
{t('sign_in.sign_in_to_comment')}
@@ -4,13 +4,14 @@
color: #696969;
background-color: white;
box-sizing: border-box;
padding: 2px 8px;
padding: 0px 5px;
border-radius: 2px;
font-size: 12px;
height: 28px;
height: 24px;
transition: background-color .2s cubic-bezier(.4,0,.2,1), color .2s cubic-bezier(.4,0,.2,1), border-color .2s cubic-bezier(.4,0,.2,1);
margin: 2px 0px;
letter-spacing: 0.4px;
}
.tag:hover {
@@ -39,4 +40,3 @@
font-size: 15px;
vertical-align: text-bottom;
}
@@ -36,7 +36,7 @@ class TabPane extends React.Component {
{featuredComments.hasNextPage &&
<LoadMore
loadMore={this.loadMore}
loadingState={this.loadingState}
loadingState={this.state.loadingState}
/>
}
</div>
@@ -2,7 +2,6 @@ import React from 'react';
import {gql} from 'react-apollo';
import {connect} from 'react-redux';
import Comment from 'coral-admin/src/routes/Moderation/containers/Comment';
import {handleCommentChange} from 'coral-admin/src/graphql/utils';
import {getDefinitionName} from 'coral-framework/utils';
import truncate from 'lodash/truncate';
import t from 'coral-framework/services/i18n';
@@ -22,20 +21,14 @@ class ModSubscription extends React.Component {
assetId: this.props.data.variables.asset_id,
},
updateQuery: (prev, {subscriptionData: {data: {commentFeatured: {user, comment}}}}) => {
const sort = this.props.data.variables.sort;
const text = this.props.user.id === user.id
? {}
const notify = this.props.user.id === user.id
? ''
: t(
'talk-plugin-featured-comments.notify_featured',
user.username,
prepareNotificationText(comment.body),
);
const notify = {
activeQueue: this.props.activeTab,
text,
anyQueue: true,
};
return handleCommentChange(prev, comment, sort, notify);
return this.props.handleCommentChange(prev, comment, notify);
},
},
{
@@ -44,20 +37,14 @@ class ModSubscription extends React.Component {
assetId: this.props.data.variables.asset_id,
},
updateQuery: (prev, {subscriptionData: {data: {commentUnfeatured: {user, comment}}}}) => {
const sort = this.props.data.variables.sort;
const text = this.props.user.id === user.id
? {}
const notify = this.props.user.id === user.id
? ''
: t(
'talk-plugin-featured-comments.notify_unfeatured',
user.username,
prepareNotificationText(comment.body),
);
const notify = {
activeQueue: this.props.activeTab,
text,
anyQueue: true,
};
return handleCommentChange(prev, comment, sort, notify);
return this.props.handleCommentChange(prev, comment, notify);
}
},
];
+5 -1
View File
@@ -22,6 +22,10 @@ if (JWT_SECRETS) {
throw new Error('when multiple keys are specified, kid\'s must be specified');
}
if (typeof secret.kid !== 'string' || secret.kid.length === 0) {
throw new Error('kid must be a unique string');
}
// HMAC secrets do not have public/private keys.
if (JWT_ALG.startsWith('HS')) {
return new jwt.SharedSecret(secret, JWT_ALG);
@@ -34,7 +38,7 @@ if (JWT_SECRETS) {
return new jwt.AsymmetricSecret(secret, JWT_ALG);
}));
debug(`loaded ${JWT_SECRET.length} ${JWT_ALG.startsWith('HS') ? 'shared' : 'asymmetric'} secrets`);
debug(`loaded ${JWT_SECRETS.length} ${JWT_ALG.startsWith('HS') ? 'shared' : 'asymmetric'} secrets`);
} else if (JWT_SECRET) {
if (JWT_ALG.startsWith('HS')) {
module.exports.jwt = new jwt.SharedSecret({
+2 -1
View File
@@ -173,7 +173,8 @@ module.exports = class ActionsService {
return ActionModel.aggregate([
{$match},
{$group},
{$project}
{$project},
{$sort: {action_type: 1, group_id: 1}},
]);
}
+13 -2
View File
@@ -1,4 +1,5 @@
const jwt = require('jsonwebtoken');
const uniq = require('lodash/uniq');
/**
* MultiSecret will take many secrets and provide a unified interface for
@@ -6,6 +7,12 @@ const jwt = require('jsonwebtoken');
*/
class MultiSecret {
constructor(secrets) {
this.kids = secrets.map(({kid}) => kid);
if (uniq(this.kids).length !== secrets.length) {
throw new Error('Duplicate kid\'s cannot be used to construct a MultiSecret');
}
this.secrets = secrets;
}
@@ -86,7 +93,11 @@ class Secret {
/**
* SharedSecret is the HMAC based secret that's used for signing/verifying.
*/
function SharedSecret({kid = undefined, secret}, algorithm) {
function SharedSecret({kid = undefined, secret = null}, algorithm) {
if (secret === null || secret.length === 0) {
throw new Error('Secret cannot have a zero length');
}
return new Secret({
kid,
signingKey: secret,
@@ -101,7 +112,7 @@ function SharedSecret({kid = undefined, secret}, algorithm) {
*/
function AsymmetricSecret({kid = undefined, private: privateKey, public: publicKey}, algorithm) {
publicKey = Buffer.from(publicKey.replace(/\\n/g, '\n'));
privateKey = privateKey ? Buffer.from(privateKey.replace(/\\n/g, '\n')) : null;
privateKey = privateKey && privateKey.length > 0 ? Buffer.from(privateKey.replace(/\\n/g, '\n')) : null;
return new Secret({
kid,
+38 -20
View File
@@ -1,4 +1,5 @@
const passport = require('passport');
const {set, get} = require('lodash');
const UsersService = require('./users');
const SettingsService = require('./settings');
const TokensService = require('./tokens');
@@ -22,22 +23,28 @@ const {
JWT_ALG,
RECAPTCHA_SECRET,
RECAPTCHA_ENABLED,
JWT_COOKIE_NAME,
JWT_CLEAR_COOKIE_LOGOUT
JWT_SIGNING_COOKIE_NAME,
JWT_COOKIE_NAMES,
JWT_CLEAR_COOKIE_LOGOUT,
JWT_USER_ID_CLAIM,
} = require('../config');
const {
jwt: JWT_SECRET
jwt
} = require('../secrets');
// GenerateToken will sign a token to include all the authorization information
// needed for the front end.
const GenerateToken = (user) => {
return JWT_SECRET.sign({}, {
const claims = {};
// Set the user id.
set(claims, JWT_USER_ID_CLAIM, user.id);
return jwt.sign(claims, {
jwtid: uuid.v4(),
expiresIn: JWT_EXPIRY,
issuer: JWT_ISSUER,
subject: user.id,
audience: JWT_AUDIENCE,
algorithm: JWT_ALG
});
@@ -47,7 +54,7 @@ const GenerateToken = (user) => {
const SetTokenForSafari = (req, res, token) => {
const browser = bowser._detect(req.headers['user-agent']);
if (browser.ios || browser.safari) {
res.cookie(JWT_COOKIE_NAME, token, {
res.cookie(JWT_SIGNING_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: new Date(Date.now() + ms(JWT_EXPIRY))
@@ -163,7 +170,7 @@ const HandleLogout = (req, res, next) => {
// Only clear the cookie on logout if enabled.
if (JWT_CLEAR_COOKIE_LOGOUT) {
res.clearCookie(JWT_COOKIE_NAME);
res.clearCookie(JWT_SIGNING_COOKIE_NAME);
}
res.status(204).end();
@@ -191,30 +198,38 @@ const CheckBlacklisted = async (jwt) => {
// Check to see if this is a PAT.
if (jwt.pat) {
return TokensService.validate(jwt.sub, jwt.jti);
return TokensService.validate(get(jwt, JWT_USER_ID_CLAIM), jwt.jti);
}
// It wasn't a PAT! Check to see if it is valid anyways.
return checkGeneralTokenBlacklist(jwt);
await checkGeneralTokenBlacklist(jwt);
return null;
};
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
let cookieExtractor = function(req) {
let token = null;
let cookieExtractor = (req) => {
if (req && req.cookies) {
token = req.cookies[JWT_COOKIE_NAME];
// Walk over all the cookie names in JWT_COOKIE_NAMES.
for (const cookieName of JWT_COOKIE_NAMES) {
// Check to see if that cookie is set.
if (cookieName in req.cookies && req.cookies[cookieName] !== null && req.cookies[cookieName].length > 0) {
return req.cookies[cookieName];
}
}
}
return token;
return null;
};
// Override the JwtVerifier method on the JwtStrategy so we can pack the
// original token into the payload.
JwtStrategy.JwtVerifier = (token, secretOrKey, options, callback) => {
return JWT_SECRET.verify(token, options, (err, jwt) => {
return jwt.verify(token, options, (err, jwt) => {
if (err) {
return callback(err);
}
@@ -236,7 +251,7 @@ passport.use(new JwtStrategy({
// Use the secret passed in which is loaded from the environment. This can be
// a certificate (loaded) or a HMAC key.
secretOrKey: JWT_SECRET,
secretOrKey: jwt,
// Verify the issuer.
issuer: JWT_ISSUER,
@@ -257,11 +272,14 @@ passport.use(new JwtStrategy({
try {
// Check to see if the token has been revoked
await CheckBlacklisted(jwt);
let user = await CheckBlacklisted(jwt);
// Try to get the user from the database or crack it from the token and
// plugin integrations.
let user = await UsersService.findOrCreateByIDToken(jwt.sub, {token, jwt});
if (user === null) {
// Try to get the user from the database or crack it from the token and
// plugin integrations.
user = await UsersService.findOrCreateByIDToken(get(jwt, JWT_USER_ID_CLAIM), {token, jwt});
}
// Attach the JWT to the request.
req.jwt = jwt;
+2 -2
View File
@@ -205,8 +205,8 @@ class TagsService {
return updateModel(item_type, query, {
$pull: {
tags: {
name: link.tag.name
}
'tag.name': link.tag.name,
},
}
});
}
+8 -3
View File
@@ -1,10 +1,12 @@
const errors = require('../errors');
const UserModel = require('../models/user');
const uuid = require('uuid');
const {set} = require('lodash');
const {
JWT_ISSUER,
JWT_AUDIENCE
JWT_AUDIENCE,
JWT_USER_ID_CLAIM,
} = require('../config');
const {
@@ -30,10 +32,11 @@ module.exports = class TokenService {
jti: uuid.v4(),
iss: JWT_ISSUER,
aud: JWT_AUDIENCE,
sub: userID,
pat: true
};
set(payload, JWT_USER_ID_CLAIM, userID);
// Sign the payload.
const jwt = JWT_SECRET.sign(payload, {});
@@ -93,7 +96,7 @@ module.exports = class TokenService {
// Find the user.
let user = await UserModel.findOne({
id: userID
}).select('tokens');
});
if (!user || !user.tokens) {
throw new errors.ErrAuthentication('user does not exist');
}
@@ -108,6 +111,8 @@ module.exports = class TokenService {
if (!token.active) {
throw new errors.ErrAuthentication('token is not active');
}
return user;
}
/**
+2 -2
View File
@@ -43,7 +43,7 @@ describe('/api/v1/assets', () => {
.set(passport.inject({roles: ['ADMIN']}))
.then((res) => {
const body = res.body;
expect(body).to.have.property('count', 2);
expect(body).to.have.property('result');
@@ -129,7 +129,7 @@ describe('/api/v1/assets', () => {
return AssetsService.findOrCreateByUrl('http://test.com')
.then((asset) => {
expect(asset).to.have.property('isClosed', null);
expect(asset).to.have.property('isClosed', false);
expect(asset).to.have.property('closedAt', null);
return chai.request(app)
+40 -2
View File
@@ -27,7 +27,7 @@ describe('services.TagsService', () => {
const id = comment.id;
const name = 'BEST';
const assigned_by = user.id;
await TagsService.add(id, 'COMMENTS', {
tag: {
name
@@ -45,7 +45,7 @@ describe('services.TagsService', () => {
const id = comment.id;
const name = 'BEST';
const assigned_by = user.id;
await TagsService.add(id, 'COMMENTS', {
tag: {
name
@@ -103,5 +103,43 @@ describe('services.TagsService', () => {
expect(tags.length).to.equal(0);
}
});
it('removes a tag out of 2', async () => {
const id = comment.id;
const name = 'BEST';
const assigned_by = user.id;
await TagsService.add(id, 'COMMENTS', {
tag: {
name: 'ANOTHER'
},
assigned_by
});
await TagsService.add(id, 'COMMENTS', {
tag: {
name
},
assigned_by
});
{
const {tags} = await CommentsService.findById(id);
expect(tags.length).to.equal(2);
}
// ok now to remove it
await TagsService.remove(id, 'COMMENTS', {
tag: {
name
},
assigned_by
});
{
const {tags} = await CommentsService.findById(id);
expect(tags.length).to.equal(1);
expect(tags[0].tag.name).to.equal('ANOTHER');
}
});
});
});
+79 -17
View File
@@ -162,6 +162,10 @@ ansi-escapes@^1.1.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
ansi-escapes@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-2.0.0.tgz#5bae52be424878dd9783e8910e3fc2922e83c81b"
ansi-regex@^1.0.0, ansi-regex@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-1.1.1.tgz#41c847194646375e6a1a5d10c3ca054ef9fc980d"
@@ -170,10 +174,20 @@ ansi-regex@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
ansi-regex@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
ansi-styles@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
ansi-styles@^3.1.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.0.tgz#c159b8d5be0f9e5a6f346dab94f16ce022161b88"
dependencies:
color-convert "^1.9.0"
any-promise@^0.1.0, any-promise@~0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-0.1.0.tgz#830b680aa7e56f33451d4b049f3bd8044498ee27"
@@ -1498,6 +1512,14 @@ chalk@1.1.3, chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3:
strip-ansi "^3.0.0"
supports-color "^2.0.0"
chalk@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.1.0.tgz#ac5becf14fa21b99c6c92ca7a7d7cfd5b17e743e"
dependencies:
ansi-styles "^3.1.0"
escape-string-regexp "^1.0.5"
supports-color "^4.0.0"
change-emitter@^0.1.2:
version "0.1.6"
resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515"
@@ -1727,7 +1749,7 @@ codemirror@*:
version "5.25.2"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.25.2.tgz#8c77677ca9c9248d757d3a07ed1e89a8404850b7"
color-convert@^1.3.0:
color-convert@^1.3.0, color-convert@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a"
dependencies:
@@ -3053,10 +3075,12 @@ extend@^1.2.1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/extend/-/extend-1.3.0.tgz#d1516fb0ff5624d2ebf9123ea1dac5a1994004f8"
external-editor@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.0.1.tgz#4c597c6c88fa6410e41dbbaa7b1be2336aa31095"
external-editor@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.0.4.tgz#1ed9199da9cbfe2ef2f7a31b2fde8b0d12368972"
dependencies:
iconv-lite "^0.4.17"
jschardet "^1.4.2"
tmp "^0.0.31"
extglob@^0.3.1:
@@ -3757,6 +3781,10 @@ has-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
has-flag@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51"
has-unicode@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
@@ -3972,6 +4000,10 @@ iconv-lite@0.4.15, iconv-lite@^0.4.5, iconv-lite@~0.4.13:
version "0.4.15"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.15.tgz#fe265a218ac6a57cfe854927e9d04c19825eddeb"
iconv-lite@^0.4.17:
version "0.4.18"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2"
icss-replace-symbols@1.0.2, icss-replace-symbols@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.0.2.tgz#cb0b6054eb3af6edc9ab1d62d01933e2d4c8bfa5"
@@ -4103,22 +4135,23 @@ inquirer@0.8.2:
rx "^2.4.3"
through "^2.3.6"
inquirer@^3.0.6:
version "3.0.6"
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.0.6.tgz#e04aaa9d05b7a3cb9b0f407d04375f0447190347"
inquirer@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.2.1.tgz#06ceb0f540f45ca548c17d6840959878265fa175"
dependencies:
ansi-escapes "^1.1.0"
chalk "^1.0.0"
ansi-escapes "^2.0.0"
chalk "^2.0.0"
cli-cursor "^2.1.0"
cli-width "^2.0.0"
external-editor "^2.0.1"
external-editor "^2.0.4"
figures "^2.0.0"
lodash "^4.3.0"
mute-stream "0.0.7"
run-async "^2.2.0"
rx "^4.1.0"
string-width "^2.0.0"
strip-ansi "^3.0.0"
rx-lite "^4.0.8"
rx-lite-aggregates "^4.0.8"
string-width "^2.1.0"
strip-ansi "^4.0.0"
through "^2.3.6"
interpret@^1.0.0:
@@ -4560,6 +4593,10 @@ jsbn@~0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
jschardet@^1.4.2:
version "1.5.1"
resolved "https://registry.yarnpkg.com/jschardet/-/jschardet-1.5.1.tgz#c519f629f86b3a5bedba58a88d311309eec097f9"
jsdom@^7.0.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-7.2.2.tgz#40b402770c2bda23469096bee91ab675e3b1fc6e"
@@ -7434,6 +7471,16 @@ run-async@^2.2.0:
dependencies:
is-promise "^2.1.0"
rx-lite-aggregates@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz#753b87a89a11c95467c4ac1626c4efc4e05c67be"
dependencies:
rx-lite "*"
rx-lite@*, rx-lite@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444"
rx-lite@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102"
@@ -7442,10 +7489,6 @@ rx@^2.4.3:
version "2.5.3"
resolved "https://registry.yarnpkg.com/rx/-/rx-2.5.3.tgz#21adc7d80f02002af50dae97fd9dbf248755f566"
rx@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
safe-buffer@^5.0.1, safe-buffer@~5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
@@ -7833,6 +7876,13 @@ string-width@^2.0.0:
is-fullwidth-code-point "^2.0.0"
strip-ansi "^3.0.0"
string-width@^2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
dependencies:
is-fullwidth-code-point "^2.0.0"
strip-ansi "^4.0.0"
string.prototype.codepointat@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.0.tgz#6b26e9bd3afcaa7be3b4269b526de1b82000ac78"
@@ -7871,6 +7921,12 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1:
dependencies:
ansi-regex "^2.0.0"
strip-ansi@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
dependencies:
ansi-regex "^3.0.0"
strip-bom@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
@@ -7961,6 +8017,12 @@ supports-color@^3.1.2, supports-color@^3.2.3:
dependencies:
has-flag "^1.0.0"
supports-color@^4.0.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.2.1.tgz#65a4bb2631e90e02420dba5554c375a4754bb836"
dependencies:
has-flag "^2.0.0"
svgo@^0.7.0:
version "0.7.2"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5"