Merge branch 'master' into fix-permalink-liveupdates

This commit is contained in:
Kim Gardner
2017-08-28 16:17:40 +01:00
committed by GitHub
174 changed files with 2960 additions and 3638 deletions
+2 -1
View File
@@ -6,7 +6,7 @@ node_modules
scripts
!scripts/generateIntrospectionResult.js
# documentation should not be visable in production.
# documentation should not be visible in production.
docs
# static assets are rebuild in the docker container.
@@ -14,6 +14,7 @@ dist
# tests are not run in the docker container.
test
__tests__
# we won't use the .git folder in production.
.git
+6 -1
View File
@@ -1,6 +1,5 @@
dist
docs
client/lib
**/*.html
plugins/*
!plugins/talk-plugin-facebook-auth
@@ -14,5 +13,11 @@ plugins/*
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-permalink
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-sort-most-liked
!plugins/talk-plugin-sort-most-loved
!plugins/talk-plugin-sort-most-respected
node_modules
+6 -1
View File
@@ -14,7 +14,6 @@ client/coral-framework/graphql/introspection.json
*.swp
*.DS_STORE
test/e2e/reports
coverage/
plugins.json
@@ -30,5 +29,11 @@ plugins/*
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-permalink
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-sort-most-liked
!plugins/talk-plugin-sort-most-loved
!plugins/talk-plugin-sort-most-respected
**/node_modules/*
+4 -4
View File
@@ -1,4 +1,4 @@
FROM node:8
FROM node:8-alpine
# Create app directory
RUN mkdir -p /usr/src/app
@@ -12,6 +12,9 @@ EXPOSE 5000
# Bundle app source
COPY . /usr/src/app
# Ensure the runtime of the container is in production mode.
ENV NODE_ENV production
# Install app dependencies and build static assets.
RUN yarn global add node-gyp && \
yarn install --frozen-lockfile && \
@@ -19,7 +22,4 @@ RUN yarn global add node-gyp && \
yarn build && \
yarn cache clean
# Ensure the runtime of the container is in production mode.
ENV NODE_ENV production
CMD ["yarn", "start"]
+2 -5
View File
@@ -7,8 +7,5 @@ ONBUILD COPY . /usr/src/app
# we need to have webpack available. We then build the new dependancies and
# clear out the development dependancies again. After this we of course need to
# clear out the yarn cache, this saves quite a lot of size.
ONBUILD RUN NODE_ENV=development yarn install --frozen-lockfile && \
NODE_ENV=production cli plugins reconcile && \
NODE_ENV=production yarn build && \
NODE_ENV=production yarn install --production --force && \
yarn cache clean
ONBUILD RUN cli plugins reconcile && \
yarn build
+1
View File
@@ -16,6 +16,7 @@ program
.command('token', 'work with the access tokens')
.command('users', 'work with the application auth')
.command('migration', 'provides utilities for migrating the database')
.command('verify', 'provides utilities for performing data verification')
.command(
'plugins',
'provides utilities for interacting with the plugin system'
Executable
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('./commander');
const mongoose = require('../services/mongoose');
const util = require('./util');
const databaseVerifications = require('./verifications/database');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
async function database({fix = false, limit = Infinity, batch = 1000}) {
try {
for (const verification of databaseVerifications) {
await verification({fix, limit, batch});
}
} catch (err) {
console.error(`Failed to process all the ${databaseVerifications.length} verifications`, err);
util.shutdown(1);
return;
}
util.shutdown();
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('db')
.description('verifies the database integrity')
.option('-f, --fix', 'fix the problems found with database inconsistencies')
.option('-l, --limit [size]', 'limit the amount of documents to process in a single pass, this will ensure only a maximum number of batch operations are issued [default: inf]', parseInt)
.option('-b, --batch [size]', 'batch size to process verifications and repairs of documents [default: 1000]', parseInt)
.action(database);
program.parse(process.argv);
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
util.shutdown();
}
+193
View File
@@ -0,0 +1,193 @@
const CommentModel = require('../../../models/comment');
const ActionsService = require('../../../services/actions');
const {arrayJoinBy, singleJoinBy} = require('../../../graph/loaders/util');
const sc = require('snake-case');
const debug = require('debug')('talk:cli:verify');
const getBatch = async (limit, offset) => CommentModel
.find({})
.select({'id': 1, 'action_counts': 1, 'reply_count': 1})
.limit(limit)
.skip(offset)
.sort('created_at');
module.exports = async ({fix, limit, batch}) => {
let operations = [];
// Count how many comments there are to process.
const totalCount = await CommentModel.count();
let offset = 0;
let comments = [];
let commentIDs = [];
console.log(`Processing ${totalCount} comments in batches of ${limit}...`);
// Keep processing documents until there are is none left.
while (offset < totalCount) {
// Get a batch of comments.
comments = await getBatch(batch, offset);
commentIDs = comments.map(({id}) => id);
// Get their reply counts.
let allReplyCounts = await CommentModel
.aggregate([
{
$match: {
parent_id: {
$in: commentIDs,
},
status: {
$in: ['NONE', 'ACCEPTED']
}
}
},
{
$group: {
_id: '$parent_id',
count: {
$sum: 1
}
}
}
])
.then(singleJoinBy(commentIDs, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
// Get their action summaries.
let allActionSummaries = await ActionsService
.getActionSummaries(commentIDs)
.then(arrayJoinBy(commentIDs, 'item_id'));
// Loop over the comments, with their action summaries.
for (let i = 0; i < comments.length; i++) {
let comment = comments[i];
let actionSummaries = allActionSummaries[i];
let replyCount = allReplyCounts[i];
// And check to see if the action summaries we just computed match what is
// currently set for the comments.
let commentOperations = [];
// If the reply count needs to be updated, then update it!
if (comment.reply_count !== replyCount) {
commentOperations.push({
reply_count: replyCount,
});
}
// First we process all the group id's.
for (let actionSummary of actionSummaries) {
if (actionSummary.group_id === null) {
continue;
}
// And we generate the group id.
const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase());
const GROUP_ID = sc(actionSummary.group_id.toLowerCase());
if (GROUP_ID.length <= 0) {
continue;
}
// And we add a new batch operation if the action summary is associated
// with a group.
const ACTION_COUNT_FIELD = `${ACTION_TYPE}_${GROUP_ID}`;
// Check that the action summaries match the cached counts.
if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== actionSummary.count) {
// Batch updates for those changes.
commentOperations.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: actionSummary.count,
});
}
}
// Group all the action summaries together from all the different group
// ids.
let groupedActionSummaries = actionSummaries.reduce((acc, actionSummary) => {
const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase());
if (!(ACTION_TYPE in acc)) {
acc[ACTION_TYPE] = 0;
}
acc[ACTION_TYPE] += actionSummary.count;
return acc;
}, {});
for (const ACTION_COUNT_FIELD of Object.keys(groupedActionSummaries)) {
const count = groupedActionSummaries[ACTION_COUNT_FIELD];
// Check that the action summaries match the cached counts.
if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== count) {
// Batch updates for those changes.
commentOperations.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: count,
});
}
}
// If this comment has action summaries that should be updated, then
// perform an update!
if (commentOperations.length > 0) {
operations.push({
updateOne: {
filter: {
id: comment.id
},
update: {
$set: Object.assign({}, ...commentOperations),
},
},
});
}
}
debug(`Processed batch of ${comments.length} comments.`);
if (operations.length >= limit) {
debug(`Queued operations are ${operations.length}, reached limit of ${limit}, not processing any more.`);
if (operations.length > limit) {
debug(`${operations.length - limit} operations have been truncated to enforce the limit`);
}
break;
}
offset += batch;
}
const OPERATIONS_LENGTH = operations.length;
if (limit < Infinity && offset + comments.length < totalCount) {
console.log(`Processed ${offset + comments.length}/${totalCount} comments because we reached the update limit of ${limit}.`);
} else {
console.log(`Processed all ${totalCount} comments.`);
}
console.log(`${OPERATIONS_LENGTH} documents need fixing.`);
// If fix was enabled, execute the batch writes.
if (OPERATIONS_LENGTH > 0) {
if (fix) {
debug(`Fixing ${OPERATIONS_LENGTH} documents...`);
while (operations.length) {
let batchOperations = operations.splice(0, batch);
let result = await CommentModel.collection.bulkWrite(batchOperations);
debug(`Fixed batch of ${result.modifiedCount} documents.`);
}
console.log(`Applied all ${OPERATIONS_LENGTH} fixes.`);
} else {
console.warn('Skipping fixing, --fix was not enabled, pass --fix to fix these errors');
}
}
};
+12
View File
@@ -0,0 +1,12 @@
// This will import all the verifications that should be run by the:
//
// cli verify database
//
// command. They exist in the form:
//
// async ({fix = false, batch = 1000}) => {}
//
// where their options are derrived.
module.exports = [
require('./comments'),
];
-2
View File
@@ -40,8 +40,6 @@ test:
override:
# Run the tests using the junit reporter.
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml MOCHA_REPORTER=mocha-junit-reporter yarn test
# Run the e2e test suite.
# - E2E_REPORT_PATH=$CIRCLE_TEST_REPORTS/e2e yarn e2e
deployment:
release:
@@ -124,7 +124,7 @@ class UserDetailContainer extends React.Component {
}
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
...CoralAdmin_Moderation_CommentConnection
}
@@ -6,7 +6,7 @@ const initialState = {
storySearchVisible: false,
storySearchString: '',
shortcutsNoteVisible: 'show',
sortOrder: 'REVERSE_CHRONOLOGICAL',
sortOrder: 'DESC',
};
export default function moderation (state = initialState, action) {
@@ -22,10 +22,10 @@ class DashboardContainer extends React.Component {
export const witDashboardQuery = withQuery(gql`
query CoralAdmin_Dashboard($from: Date!, $to: Date!) {
assetsByFlag: assetMetrics(from: $from, to: $to, sort: FLAG) {
assetsByFlag: assetMetrics(from: $from, to: $to, sortBy: FLAG) {
...CoralAdmin_Metrics
}
assetsByActivity: assetMetrics(from: $from, to: $to, sort: ACTIVITY) {
assetsByActivity: assetMetrics(from: $from, to: $to, sortBy: ACTIVITY) {
...CoralAdmin_Metrics
}
}
@@ -36,8 +36,8 @@ const ModerationMenu = ({
label="Sort"
value={sort}
onChange={(sort) => selectSort(sort)}>
<Option value={'REVERSE_CHRONOLOGICAL'}>{t('modqueue.newest_first')}</Option>
<Option value={'CHRONOLOGICAL'}>{t('modqueue.oldest_first')}</Option>
<Option value={'DESC'}>{t('modqueue.newest_first')}</Option>
<Option value={'ASC'}>{t('modqueue.oldest_first')}</Option>
</SelectField>
</div>
</div>
@@ -59,7 +59,7 @@ class ModerationContainer extends Component {
return handleCommentChange(
root,
comment,
this.props.data.variables.sort,
this.props.data.variables.sortOrder,
() => notifyText && this.props.notify('info', notifyText),
this.props.queueConfig,
this.activeTab
@@ -168,7 +168,7 @@ class ModerationContainer extends Component {
const variables = {
limit: 10,
cursor: this.props.root[tab].endCursor,
sort: this.props.data.variables.sort,
sortOrder: this.props.data.variables.sortOrder,
asset_id: this.props.data.variables.asset_id,
statuses: this.props.queueConfig[tab].statuses,
action_type: this.props.queueConfig[tab].action_type,
@@ -288,8 +288,8 @@ const COMMENT_REJECTED_SUBSCRIPTION = gql`
`;
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort, action_type: $action_type}) {
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $sortOrder: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type}) {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
}
@@ -314,14 +314,14 @@ const commentConnectionFragment = gql`
`;
const withModQueueQuery = withQuery(({queueConfig}) => gql`
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!) {
${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
sortOrder: $sortOrder
}) {
...CoralAdmin_Moderation_CommentConnection
}
@@ -354,7 +354,7 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
return {
variables: {
asset_id: id,
sort: props.moderation.sortOrder,
sortOrder: props.moderation.sortOrder,
allAssets: id === null
}
};
@@ -28,29 +28,29 @@ function removeCommentFromQueue(root, queue, id) {
});
}
function shouldCommentBeAdded(root, queue, comment, sort) {
function shouldCommentBeAdded(root, queue, comment, sortOrder) {
if (root[`${queue}Count`] < limit) {
// Adding all comments until first limit has reached.
return true;
}
const cursor = new Date(root[queue].endCursor);
return sort === 'CHRONOLOGICAL'
return sortOrder === 'ASC'
? new Date(comment.created_at) <= cursor
: new Date(comment.created_at) >= cursor;
}
function addCommentToQueue(root, queue, comment, sort) {
function addCommentToQueue(root, queue, comment, sortOrder) {
if (queueHasComment(root, queue, comment.id)) {
return root;
}
const sortAlgo = sort === 'CHRONOLOGICAL' ? ascending : descending;
const sortAlgo = sortOrder === 'ASC' ? ascending : descending;
const changes = {
[`${queue}Count`]: {$set: root[`${queue}Count`] + 1},
};
if (shouldCommentBeAdded(root, queue, comment, sort)) {
if (shouldCommentBeAdded(root, queue, comment, sortOrder)) {
const nodes = root[queue].nodes.concat(comment).sort(sortAlgo);
changes[queue] = {
nodes: {$set: nodes},
@@ -90,13 +90,13 @@ function getCommentQueues(comment, queueConfig) {
* Assimilate comment changes into current store.
* @param {Object} root current state of the store
* @param {Object} comment comment that was changed
* @param {string} sort current sort order of the queues
* @param {string} sortOrder current sort order of the queues
* @param {string} notify callback to show notification
* 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, queueConfig, activeQueue) {
export function handleCommentChange(root, comment, sortOrder, notify, queueConfig, activeQueue) {
let next = root;
const nextQueues = getCommentQueues(comment, queueConfig);
@@ -113,8 +113,8 @@ export function handleCommentChange(root, comment, sort, notify, queueConfig, ac
Object.keys(queueConfig).forEach((queue) => {
if (nextQueues.indexOf(queue) >= 0) {
if (!queueHasComment(next, queue, comment.id)) {
next = addCommentToQueue(next, queue, comment, sort);
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
next = addCommentToQueue(next, queue, comment, sortOrder);
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sortOrder)) {
showNotificationOnce();
}
}
@@ -4,6 +4,8 @@ import queryString from 'query-string';
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
export const setSort = ({sortOrder, sortBy}) => ({type: actions.SET_SORT, sortOrder, sortBy});
export const viewAllComments = () => (dispatch, _, {pym}) => {
const search = queryString.stringify({
+158 -107
View File
@@ -13,7 +13,7 @@ import t, {timeago} from 'coral-framework/services/i18n';
import CommentBox from 'talk-plugin-commentbox/CommentBox';
import QuestionBox from 'talk-plugin-questionbox/QuestionBox';
import {isCommentActive} from 'coral-framework/utils';
import {Button, Tab, TabCount, TabPane} from 'coral-ui';
import {Spinner, Button, Tab, TabCount, TabPane} from 'coral-ui';
import cn from 'classnames';
import {getTopLevelParent, attachCommentToParent} from '../graphql/utils';
@@ -23,6 +23,8 @@ import StreamTabPanel from '../containers/StreamTabPanel';
import styles from './Stream.css';
const SpinnerWhenLoading = ({loading, children}) => loading ? <Spinner /> : <div>{children}</div>;
class Stream extends React.Component {
constructor(props) {
@@ -49,15 +51,14 @@ class Stream extends React.Component {
);
};
render() {
renderHighlightedComment() {
const {
data,
root,
activeReplyBox,
setActiveReplyBox,
appendItemArray,
commentClassNames,
root: {asset, asset: {comment, comments, totalCommentCount}},
root: {asset, asset: {comment}},
postComment,
notify,
editComment,
@@ -65,23 +66,15 @@ class Stream extends React.Component {
postDontAgree,
deleteAction,
showSignInDialog,
updateItem,
ignoreUser,
activeStreamTab,
setActiveStreamTab,
loadNewReplies,
loadMoreComments,
viewAllComments,
auth: {loggedIn, user},
editName,
auth: {user},
emit,
} = this.props;
const {keepCommentBox} = this.state;
const open = !asset.isClosed;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
let highlightedComment = comment && getTopLevelParent(comment);
if (highlightedComment) {
let topLevelComment = getTopLevelParent(comment);
if (topLevelComment) {
// Inactive comments can be viewed by moderators and admins (e.g. using permalinks).
const isInactive = !isCommentActive(comment.status);
@@ -89,10 +82,152 @@ class Stream extends React.Component {
// the highlighted comment is not active and as such not in the replies, so we
// attach it to the right parent.
highlightedComment = attachCommentToParent(highlightedComment, comment);
topLevelComment = attachCommentToParent(topLevelComment, comment);
}
}
return (
<div className={cn('talk-stream-highlighted-container', styles.highlightedContainer)}>
<Comment
data={data}
root={root}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
notify={notify}
depth={0}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
highlighted={comment.id}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={topLevelComment.id}
commentIsIgnored={this.commentIsIgnored}
comment={topLevelComment}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
liveUpdates={true}
/>
</div>
);
}
renderTabPanel() {
const {
data,
root,
activeReplyBox,
setActiveReplyBox,
commentClassNames,
root: {asset, asset: {comments, totalCommentCount}},
postComment,
notify,
editComment,
postFlag,
postDontAgree,
deleteAction,
showSignInDialog,
ignoreUser,
activeStreamTab,
setActiveStreamTab,
loadNewReplies,
loadMoreComments,
auth: {user},
emit,
sortOrder,
sortBy,
} = this.props;
const slotProps = {data};
const slotQueryData = {root, asset};
// `key` of `StreamTabPanel` depends on sorting so that we always reset
// the state when changing sorting.
return (
<div className={cn('talk-stream-tab-container', styles.tabContainer)}>
<div
className={cn('talk-stream-filter-wrapper', styles.filterWrapper)}
>
<Slot
fill="streamFilter"
queryData={slotQueryData}
{...slotProps}
/>
</div>
<StreamTabPanel
key={`${sortBy}_${sortOrder}`}
activeTab={activeStreamTab}
setActiveTab={setActiveStreamTab}
fallbackTab={'all'}
tabSlot={'streamTabs'}
tabPaneSlot={'streamTabPanes'}
slotProps={slotProps}
queryData={slotQueryData}
appendTabs={
<Tab tabId={'all'} key='all'>
All Comments <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
</Tab>
}
appendTabPanes={
<TabPane tabId={'all'} key='all'>
<AllCommentsPane
data={data}
root={root}
comments={comments}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
notify={notify}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadMoreComments}
loadNewReplies={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
commentIsIgnored={this.commentIsIgnored}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
/>
</TabPane>
}
sub
/>
</div>
);
}
render() {
const {
data,
root,
appendItemArray,
root: {asset, asset: {comment: highlightedComment, comments}},
postComment,
notify,
updateItem,
viewAllComments,
auth: {loggedIn, user},
editName,
loading,
} = this.props;
const {keepCommentBox} = this.state;
const open = !asset.isClosed;
const banned = user && user.status === 'BANNED';
const temporarilySuspended =
user &&
@@ -103,7 +238,7 @@ class Stream extends React.Component {
const slotProps = {data};
const slotQueryData = {root, asset};
if (!comment && !comments) {
if (!highlightedComment && !comments) {
console.error('Talk: No comments came back from the graph given that query. Please, check the query params.');
return <StreamError />;
}
@@ -111,7 +246,7 @@ class Stream extends React.Component {
return (
<div id="stream" className={styles.root}>
<AutomaticAssetClosure assetId={asset.id} closedAt={asset.closedAt}/>
{comment &&
{highlightedComment &&
<Button
cStyle="darkGrey"
className={cn('talk-stream-show-all-comments-button', styles.viewAllButton)}
@@ -175,96 +310,12 @@ class Stream extends React.Component {
/>
)}
{/* the highlightedComment is isolated after the user followed a permalink */}
{highlightedComment
? (
<div className={cn('talk-stream-highlighted-container', styles.highlightedContainer)}>
<Comment
data={data}
root={root}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
notify={notify}
depth={0}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
highlighted={comment.id}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={highlightedComment.id}
commentIsIgnored={this.commentIsIgnored}
comment={highlightedComment}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
liveUpdates={true}
/>
</div>
)
: <div className={cn('talk-stream-tab-container', styles.tabContainer)}>
<div
className={cn('talk-stream-filter-wrapper', styles.filterWrapper)}
>
<Slot
fill="streamFilter"
queryData={slotQueryData}
{...slotProps}
/>
</div>
<StreamTabPanel
activeTab={activeStreamTab}
setActiveTab={setActiveStreamTab}
fallbackTab={'all'}
tabSlot={'streamTabs'}
tabPaneSlot={'streamTabPanes'}
slotProps={slotProps}
queryData={slotQueryData}
appendTabs={
<Tab tabId={'all'} key='all'>
All Comments <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
</Tab>
}
appendTabPanes={
<TabPane tabId={'all'} key='all'>
<AllCommentsPane
data={data}
root={root}
comments={comments}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
notify={notify}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadMoreComments}
loadNewReplies={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
commentIsIgnored={this.commentIsIgnored}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
/>
</TabPane>
}
sub
/>
</div>
<SpinnerWhenLoading loading={loading}>
{highlightedComment
? this.renderHighlightedComment()
: this.renderTabPanel()
}
</SpinnerWhenLoading>
</div>
);
}
@@ -6,3 +6,4 @@ export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME';
export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME';
export const THREADING_LEVEL = process.env.TALK_THREADING_LEVEL;
export const SET_ACTIVE_TAB = 'CORAL_STREAM_SET_ACTIVE_TAB';
export const SET_SORT = 'CORAL_STREAM_SET_SORT';
@@ -105,7 +105,7 @@ const withCommentFragments = withFragments({
fragment CoralEmbedStream_Comment_comment on Comment {
...CoralEmbedStream_Comment_SingleComment
${nest(`
replies(limit: 3, excludeIgnored: $excludeIgnored) {
replies(query: {limit: 3, excludeIgnored: $excludeIgnored}) {
nodes {
...CoralEmbedStream_Comment_SingleComment
...nest
@@ -154,7 +154,15 @@ const slots = [
];
const EMBED_QUERY = gql`
query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) {
query CoralEmbedStream_Embed(
$assetId: ID,
$assetUrl: String,
$commentId: ID!,
$hasComment: Boolean!,
$excludeIgnored: Boolean,
$sortBy: SORT_COMMENTS_BY!,
$sortOrder: SORT_ORDER!,
) {
me {
id
status
@@ -166,13 +174,15 @@ const EMBED_QUERY = gql`
`;
export const withEmbedQuery = withQuery(EMBED_QUERY, {
options: ({auth, commentId, assetId, assetUrl}) => ({
options: ({auth, commentId, assetId, assetUrl, sortBy, sortOrder}) => ({
variables: {
assetId,
assetUrl,
commentId,
hasComment: commentId !== '',
excludeIgnored: Boolean(auth && auth.user && auth.user.id),
sortBy,
sortOrder,
},
}),
});
@@ -183,7 +193,9 @@ const mapStateToProps = (state) => ({
assetId: state.stream.assetId,
assetUrl: state.stream.assetUrl,
activeTab: state.embed.activeTab,
config: state.config
config: state.config,
sortOrder: state.stream.sortOrder,
sortBy: state.stream.sortBy,
});
const mapDispatchToProps = (dispatch) =>
@@ -29,11 +29,15 @@ const {showSignInDialog, editName} = authActions;
const {notify} = notificationActions;
class StreamContainer extends React.Component {
subscriptions = [];
commentsAddedSubscription = null;
commentsEditedSubscription = null;
subscribeToUpdates() {
const newSubscriptions = [{
subscribeToCommentsEdited() {
this.commentsEditedSubscription = this.props.data.subscribeToMore({
document: COMMENTS_EDITED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => {
// Ignore mutations from me.
@@ -51,9 +55,15 @@ class StreamContainer extends React.Component {
return removeCommentFromEmbedQuery(prev, commentEdited.id);
}
},
},
{
});
}
subscribeToCommentsAdded() {
this.commentsAddedSubscription = this.props.data.subscribeToMore({
document: COMMENTS_ADDED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => {
// Ignore mutations from me.
@@ -75,21 +85,22 @@ class StreamContainer extends React.Component {
}
return insertCommentIntoEmbedQuery(prev, commentAdded);
}
}];
this.subscriptions = newSubscriptions.map((s) => this.props.data.subscribeToMore({
document: s.document,
variables: {
assetId: this.props.root.asset.id,
},
updateQuery: s.updateQuery,
}));
});
}
unsubscribe() {
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions = [];
unsubscribeCommentsAdded() {
if (this.commentsAddedSubscription) {
this.commentsAddedSubscription();
this.commentsAddedSubscription = null;
}
}
unsubscribeCommentsEdited() {
if (this.commentsEditedSubscription) {
this.commentsEditedSubscription();
this.commentsEditedSubscription = null;
}
}
loadNewReplies = (parent_id) => {
@@ -102,7 +113,7 @@ class StreamContainer extends React.Component {
cursor: comment.replies.endCursor,
parent_id,
asset_id: this.props.root.asset.id,
sort: 'CHRONOLOGICAL',
sortOrder: 'ASC',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
@@ -119,7 +130,8 @@ class StreamContainer extends React.Component {
cursor: this.props.root.asset.comments.endCursor,
parent_id: null,
asset_id: this.props.root.asset.id,
sort: 'REVERSE_CHRONOLOGICAL',
sortOrder: this.props.data.variables.sortOrder,
sortBy: this.props.data.variables.sortBy,
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
@@ -128,36 +140,78 @@ class StreamContainer extends React.Component {
});
};
isSortedByNewestFirst({sortBy, sortOrder} = this.props) {
return sortBy === 'CREATED_AT' && sortOrder === 'DESC';
}
componentDidMount() {
if (this.props.previousTab) {
this.props.data.refetch();
}
this.subscribeToUpdates();
if (this.isSortedByNewestFirst()) {
this.subscribeToCommentsAdded();
}
this.subscribeToCommentsEdited();
}
componentWillUnmount() {
this.unsubscribe();
this.unsubscribeCommentsAdded();
this.unsubscribeCommentsEdited();
clearInterval(this.countPoll);
}
componentWillReceiveProps(nextProps) {
const prevSortedNewest = this.isSortedByNewestFirst(this.props);
const nextSortedNewest = this.isSortedByNewestFirst(nextProps);
// When switching to 'Newest first' we refetch and subscribe so that
// we always have the newest comments.
if (!prevSortedNewest && nextSortedNewest) {
nextProps.data.refetch();
this.subscribeToCommentsAdded();
}
// When switching away from 'Newest first' unsubscribe from newest comments.
if (prevSortedNewest && !nextSortedNewest) {
this.unsubscribeCommentsAdded();
}
}
shouldComponentUpdate(nextProps) {
const prevSortedNewest = this.isSortedByNewestFirst(this.props);
const nextSortedNewest = this.isSortedByNewestFirst(nextProps);
if (!prevSortedNewest && nextSortedNewest) {
// When switching to 'Newest first' we refetch => skip
// rendering this frame and wait for refetch to kick in.
return false;
}
return true;
}
userIsDegraged({auth: {user}} = this.props) {
return !can(user, 'INTERACT_WITH_COMMUNITY');
}
render() {
if (this.props.refetching
|| !this.props.root.asset
if (!this.props.root.asset
|| !this.props.root.asset.comment
&& !this.props.root.asset.comments
) {
return <Spinner />;
}
const streamLoading = this.props.refetching || this.props.data.loading;
return <Stream
{...this.props}
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewReplies={this.loadNewReplies}
userIsDegraged={this.userIsDegraged()}
loading={streamLoading}
/>;
}
}
@@ -203,8 +257,26 @@ const COMMENTS_EDITED_SUBSCRIPTION = gql`
`;
const LOAD_MORE_QUERY = gql`
query CoralEmbedStream_LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) {
comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) {
query CoralEmbedStream_LoadMoreComments(
$limit: Int = 5
$cursor: Cursor
$parent_id: ID
$asset_id: ID
$sortOrder: SORT_ORDER
$sortBy: SORT_COMMENTS_BY = CREATED_AT
$excludeIgnored: Boolean
) {
comments(
query: {
limit: $limit
cursor: $cursor
parent_id: $parent_id
asset_id: $asset_id
sortOrder: $sortOrder
sortBy: $sortBy
excludeIgnored: $excludeIgnored
}
) {
nodes {
...CoralEmbedStream_Stream_comment
}
@@ -219,6 +291,7 @@ const LOAD_MORE_QUERY = gql`
const slots = [
'streamTabs',
'streamTabPanes',
'streamFilter',
];
const fragments = {
@@ -254,9 +327,9 @@ const fragments = {
charCount
requireEmailConfirmation
}
commentCount(excludeIgnored: $excludeIgnored) @skip(if: $hasComment)
totalCommentCount(excludeIgnored: $excludeIgnored) @skip(if: $hasComment)
comments(limit: 10, excludeIgnored: $excludeIgnored) @skip(if: $hasComment) {
commentCount @skip(if: $hasComment)
totalCommentCount @skip(if: $hasComment)
comments(query: {limit: 10, excludeIgnored: $excludeIgnored, sortOrder: $sortOrder, sortBy: $sortBy}) @skip(if: $hasComment) {
nodes {
...CoralEmbedStream_Stream_comment
}
@@ -299,6 +372,8 @@ const mapStateToProps = (state) => ({
previousStreamTab: state.stream.previousTab,
commentClassNames: state.stream.commentClassNames,
pluginConfig: state.config.plugin_config,
sortOrder: state.stream.sortOrder,
sortBy: state.stream.sortBy,
});
const mapDispatchToProps = (dispatch) =>
+3 -10
View File
@@ -1,5 +1,5 @@
import update from 'immutability-helper';
import {insertCommentsSorted} from 'coral-framework/utils';
import {appendNewNodes} from 'coral-framework/utils';
function determineCommentDepth(comment) {
let depth = 0;
@@ -163,14 +163,7 @@ function findAndInsertFetchedComments(parent, comments, parent_id) {
[connectionField]: {
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
nodes: {$apply: (nodes) => {
if (isAsset) {
return nodes.concat(comments.nodes);
}
return insertCommentsSorted(nodes, comments.nodes.filter(
(comment) => !nodes.some((node) => node.id === comment.id)
));
}},
nodes: {$apply: (nodes) => appendNewNodes(nodes, comments.nodes)},
},
});
}
@@ -202,7 +195,7 @@ export function attachCommentToParent(topLevelComment, comment) {
return update(topLevelComment, {
replies: {
nodes: {
$apply: (nodes) => insertCommentsSorted(nodes, comment),
$apply: (nodes) => appendNewNodes(nodes, [comment]),
},
},
replyCount: {
@@ -23,6 +23,8 @@ const initialState = {
commentClassNames: [],
activeTab: process.env.TALK_DEFAULT_STREAM_TAB,
previousTab: '',
sortBy: 'CREATED_AT',
sortOrder: 'DESC',
};
export default function stream(state = initialState, action) {
@@ -66,6 +68,12 @@ export default function stream(state = initialState, action) {
...state.commentClassNames.slice(action.idx + 1)
]
};
case actions.SET_SORT :
return {
...state,
sortOrder: action.sortOrder ? action.sortOrder : state.sortOrder,
sortBy: action.sortBy ? action.sortBy : state.sortBy,
};
default:
return state;
}
+51 -6
View File
@@ -29,7 +29,18 @@ class Slot extends React.Component {
return changes.length !== 0;
}
getSlotProps({fill: _a, inline: _b, className: _c, reduxState: _d, defaultComponent_: _e, queryData: _f, ...rest} = this.props) {
getSlotProps(props = this.props) {
const {
fill: _a,
inline: _b,
className: _c,
reduxState: _d,
defaultComponent_: _e,
queryData: _f,
childFactory: _g,
component: _h,
...rest
} = props;
return rest;
}
@@ -39,8 +50,16 @@ class Slot extends React.Component {
}
render() {
const {
inline = false,
className,
reduxState,
component: Component,
childFactory,
defaultComponent: DefaultComponent,
queryData,
} = this.props;
const {plugins} = this.context;
const {inline = false, className, reduxState, defaultComponent: DefaultComponent, queryData} = this.props;
let children = this.getChildren();
const pluginConfig = reduxState.config.pluginConfig || emptyConfig;
if (children.length === 0 && DefaultComponent) {
@@ -48,19 +67,45 @@ class Slot extends React.Component {
children = <DefaultComponent {...props} />;
}
if (childFactory) {
children = children.map(childFactory);
}
return (
<div className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
<Component className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
{children}
</div>
</Component>
);
}
}
Slot.defaultProps = {
component: 'div',
};
Slot.propTypes = {
fill: React.PropTypes.string.isRequired,
fill: PropTypes.string.isRequired,
/**
* You may specify the component to use as the root wrapper.
* Defaults to 'div'.
*/
component: PropTypes.any,
// props coming from graphql must be passed through this property.
queryData: React.PropTypes.object,
queryData: PropTypes.object,
/**
* You may need to apply reactive updates to a child as it is exiting.
* This is generally done by using `cloneElement` however in the case of an exiting
* child the element has already been removed and not accessible to the consumer.
*
* If you do need to update a child as it leaves you can provide a `childFactory`
* to wrap every child, even the ones that are leaving.
*
* @type Function(child: ReactElement) -> ReactElement
*/
childFactory: PropTypes.func,
};
const mapStateToProps = (state) => ({
+6 -18
View File
@@ -2,6 +2,7 @@ import {gql} from 'react-apollo';
import t from 'coral-framework/services/i18n';
import union from 'lodash/union';
import {capitalize} from 'coral-framework/helpers/strings';
export * from 'coral-framework/helpers/strings';
export const getTotalActionCount = (type, comment) => {
return comment.action_summaries
@@ -153,25 +154,12 @@ export function getErrorMessages(error) {
return result;
}
const ascending = (a, b) => {
const dateA = new Date(a.created_at);
const dateB = new Date(b.created_at);
if (dateA < dateB) { return -1; }
if (dateA > dateB) { return 1; }
return 0;
};
export function appendNewNodes(nodesA, nodesB) {
return nodesA.concat(nodesB.filter((nodeB) => !nodesA.some((nodeA) => nodeA.id === nodeB.id)));
}
const descending = (a, b) => ascending(a, b) * -1;
export function insertCommentsSorted(nodes, comments, sortOrder = 'CHRONOLOGICAL') {
const added = nodes.concat(comments);
if (sortOrder === 'CHRONOLOGICAL') {
return added.sort(ascending);
}
if (sortOrder === 'REVERSE_CHRONOLOGICAL') {
return added.sort(descending);
}
throw new Error(`Unknown sort order ${sortOrder}`);
export function prependNewNodes(nodesA, nodesB) {
return nodesB.filter((nodeB) => !nodesA.some((nodeA) => nodeA.id === nodeB.id)).concat(nodesA);
}
export const isTagged = (tags, which) => tags.some((t) => t.tag.name === which);
@@ -15,7 +15,7 @@ import CommentHistory from 'talk-plugin-history/CommentHistory';
// TODO: Auth logic needs refactoring.
import {showSignInDialog, checkLogin} from 'coral-embed-stream/src/actions/auth';
import {insertCommentsSorted} from 'plugin-api/beta/client/utils';
import {appendNewNodes} from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
@@ -42,7 +42,7 @@ class ProfileContainer extends Component {
me: {
comments: {
nodes: {
$apply: (nodes) => insertCommentsSorted(nodes, comments.nodes, 'REVERSE_CHRONOLOGICAL'),
$apply: (nodes) => appendNewNodes(nodes, comments.nodes),
},
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
@@ -123,7 +123,7 @@ const CommentFragment = gql`
`;
const LOAD_MORE_QUERY = gql`
query TalkSettings_LoadMoreComments($limit: Int, $cursor: Date) {
query TalkSettings_LoadMoreComments($limit: Int, $cursor: Cursor) {
comments(query: {limit: $limit, cursor: $cursor}) {
...TalkSettings_CommentConnectionFragment
}
+13 -22
View File
@@ -9,6 +9,7 @@ require('env-rewrite').rewrite();
const uniq = require('lodash/uniq');
const ms = require('ms');
const debug = require('debug')('talk:config');
//==============================================================================
// CONFIG INITIALIZATION
@@ -240,33 +241,23 @@ CONFIG.RECAPTCHA_ENABLED =
CONFIG.RECAPTCHA_SECRET.length > 0 &&
CONFIG.RECAPTCHA_PUBLIC &&
CONFIG.RECAPTCHA_PUBLIC.length > 0;
if (!CONFIG.RECAPTCHA_ENABLED) {
console.warn(
'Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET and TALK_RECAPTCHA_PUBLIC to enable Recaptcha.'
);
}
debug(`reCAPTCHA is ${CONFIG.RECAPTCHA_ENABLED ? 'enabled' : 'disabled, required config is not present'}`);
//------------------------------------------------------------------------------
// SMTP Server configuration
//------------------------------------------------------------------------------
{
const requiredProps = [
'SMTP_FROM_ADDRESS',
'SMTP_USERNAME',
'SMTP_PASSWORD',
'SMTP_HOST'
];
CONFIG.EMAIL_ENABLED =
CONFIG.SMTP_FROM_ADDRESS &&
CONFIG.SMTP_FROM_ADDRESS.length > 0 &&
CONFIG.SMTP_USERNAME &&
CONFIG.SMTP_USERNAME.length > 0 &&
CONFIG.SMTP_PASSWORD &&
CONFIG.SMTP_PASSWORD.length > 0 &&
CONFIG.SMTP_HOST &&
CONFIG.SMTP_HOST.length > 0;
if (requiredProps.some((prop) => !CONFIG[prop])) {
console.warn(
`${requiredProps
.map((v) => `TALK_${v}`)
.join(
', '
)} should be defined in the environment if you would like to send password reset emails from Talk`
);
}
}
debug(`Email is ${CONFIG.EMAIL_ENABLED ? 'enabled' : 'disabled, required config is not present'}`);
module.exports = CONFIG;
-1
View File
@@ -72,7 +72,6 @@ You can see other scripts we've made available by consulting the `package.json`
file under the `scripts` key including:
- `yarn test` run unit tests
- `yarn e2e` run end to end tests
- `yarn build-watch` watch for changes to client files and build static assets
- `yarn dev-start` watch for changes to server files and reload the server while
also sourcing a `.env` file in your local directory for configuration
+46
View File
@@ -85,6 +85,52 @@ configure the context plugin before it would be mounted at `context.plugins`.
This plugin above would mount at: `context.plugins.Slack`, or, if you're using
[object destructuring](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), `{plugins: {Slack}}`.
##### `Sort`
A special context hook, `Sort` will allow plugin authors to provide new
methods to sort data. An example is as follows:
```js
{
Sort: () => ({
Comments: { // <-- (1)
likes: { // <-- (2)
startCursor(ctx, nodes, {cursor}) { // <-- (3)
return cursor != null ? cursor : 0;
},
endCursor(ctx, nodes, {cursor}) { // <-- (4)
return nodes.length ? (cursor != null ? cursor : 0) + nodes.length : null;
},
sort(ctx, query, {cursor, sort}) { // <-- (5)
if (cursor) {
query = query.skip(cursor);
}
return query.sort({
'action_counts.like': sort === 'DESC' ? -1 : 1,
created_at: sort === 'DESC' ? -1 : 1,
});
},
},
},
}),
}
```
This has a bunch of special features:
1. `Comments` is the name of the type being sorted, this is pluralized and
capitalized.
2. `likes` is the `sortBy` field in lowercase.
3. `startCursor` will retrieve the start cursor based on the current set of
nodes and the current cursor.
4. `endCursor` will retrieve the end cursor based on the current set of nodes
and the current cursor.
5. `sort` will mutate the `query` to apply the sort operations.
All the `startCursor`, `endCursor`, and `sort` functions must be provided in
order for the sorting to apply properly.
#### Field: `loaders`
```js
+15 -7
View File
@@ -1,6 +1,7 @@
const loaders = require('./loaders');
const mutators = require('./mutators');
const uuid = require('uuid');
const merge = require('lodash/merge');
const plugins = require('../services/plugins');
const pubsub = require('../services/pubsub');
@@ -17,17 +18,24 @@ const contextPlugins = plugins.get('server', 'context').map(({plugin, context})
});
/**
* This should itterate over the passed in plugins and load them all with the
* This should iterate over the passed in plugins and load them all with the
* current graph context.
* @return {Object} the saturated plugins object
*/
const decorateContextPlugins = (context, contextPlugins) => contextPlugins.reduce((acc, plugin) => {
Object.keys(plugin.context).forEach((service) => {
acc[service] = plugin.context[service](context);
});
const decorateContextPlugins = (context, contextPlugins) => {
return acc;
}, {});
// For each of the plugins, we execute with the context to get the context
// based plugin. We then merge that into an object for the plugin. Once the
// plugin is assembled, we merge that object with all the other objects
// provided from the other plugins.
return merge(...contextPlugins.map((plugin) => {
return Object.keys(plugin.context).reduce((services, serviceName) => {
services[serviceName] = plugin.context[serviceName](context);
return services;
}, {});
}));
};
/**
* Stores the request context.
+3 -7
View File
@@ -54,12 +54,8 @@ const findOrCreateAssetByURL = async (context, asset_url) => {
return asset;
};
const getAssetsForMetrics = async ({loaders: {Actions, Comments}}) => {
let actions = await Actions.getByTypes({action_type: 'FLAG', item_type: 'COMMENT'});
const ids = actions.map(({item_id}) => item_id);
return Comments.getByQuery({ids})
const getAssetsForMetrics = async ({loaders: {Comments}}) => {
return Comments.getByQuery({action_type: 'FLAG'})
.then((connection) => connection.nodes);
};
@@ -86,7 +82,7 @@ module.exports = (context) => ({
// TODO: decide whether we want to move these to mutators or not, as in fact
// this operation create a new asset if one isn't found.
getByURL: (url) => findOrCreateAssetByURL(context, url),
findByUrl: (url) => findByUrl(context, url),
search: (query) => getAssetsByQuery(context, query),
getByID: new DataLoader((ids) => genAssetsByID(context, ids)),
+170 -275
View File
@@ -1,7 +1,6 @@
const {
SharedCounterDataLoader,
singleJoinBy,
arrayJoinBy
} = require('./util');
const DataLoader = require('dataloader');
const {
@@ -14,7 +13,6 @@ const {
const ms = require('ms');
const CommentModel = require('../../models/comment');
const UsersService = require('../../services/users');
/**
* Returns the comment count for all comments that are public based on their
@@ -48,38 +46,6 @@ const getCountsByAssetID = (context, asset_ids) => {
.then((results) => results.map((result) => result ? result.count : 0));
};
/**
* Returns the count of all public comments on an asset id, also filtering by personalization options.
*
* @param {Array<String>} id The ID of the asset
* @param {Array<String>} excludeIgnored Exclude comments ignored by the requesting user
*/
const getCountsByAssetIDPersonalized = async (context, {assetId, excludeIgnored, tags}) => {
const query = {
asset_id: assetId,
status: {
$in: ['NONE', 'ACCEPTED'],
},
};
if (tags) {
query['tags.tag.name'] = {
$in: tags,
};
}
const user = context.user;
if (excludeIgnored && user) {
// load afresh, as `user` may be from cache and not have recent ignores
const freshUser = await UsersService.findById(user.id);
const ignoredUsers = freshUser.ignoresUsers;
query.author_id = {$nin: ignoredUsers};
}
const count = await CommentModel.where(query).count();
return count;
};
/**
* Returns the comment count for all comments that are public based on their
* asset ids.
@@ -113,98 +79,6 @@ const getParentCountsByAssetID = (context, asset_ids) => {
.then((results) => results.map((result) => result ? result.count : 0));
};
/**
* Returns the count of top-level comments on an asset id, also filtering by personalization options.
*
* @param {Array<String>} id The ID of the asset
* @param {Array<String>} excludeIgnored Exclude comments ignored by the requesting user
*/
const getParentCountByAssetIDPersonalized = async (context, {assetId, excludeIgnored, tags}) => {
const query = {
asset_id: assetId,
parent_id: null,
status: {
$in: ['NONE', 'ACCEPTED'],
},
};
if (tags) {
query['tags.tag.name'] = {
$in: tags,
};
}
const user = context.user;
if (excludeIgnored && user) {
// load afresh, as `user` may be from cache and not have recent ignores
const freshUser = await UsersService.findById(user.id);
const ignoredUsers = freshUser.ignoresUsers;
query.author_id = {$nin: ignoredUsers};
}
return CommentModel.where(query).count();
};
/**
* Returns the comment count for all comments that are public based on their
* parent ids.
*
* @param {Array<String>} parent_ids the ids of parents for which there are
* comments that we want to get
*/
const getCountsByParentID = (context, parent_ids) => {
return CommentModel.aggregate([
{
$match: {
parent_id: {
$in: parent_ids
},
status: {
$in: ['NONE', 'ACCEPTED']
}
}
},
{
$group: {
_id: '$parent_id',
count: {
$sum: 1
}
}
}
])
.then(singleJoinBy(parent_ids, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
};
/**
* Returns the count of comments for the provided parent_id, also filtering by personalization options.
*
* @param {Array<String>} id The ID of the parent comment
* @param {Array<String>} excludeIgnored Exclude comments ignored by context.user
*/
const getCountByParentIDPersonalized = async (context, {id, excludeIgnored}) => {
const query = {
parent_id: {
$in: [id]
},
status: {
$in: ['NONE', 'ACCEPTED']
}
};
const user = context.user;
if (excludeIgnored && user) {
// load afresh, as `user` may be from cache and not have recent ignores
const freshUser = await UsersService.findById(user.id);
const ignoredUsers = freshUser.ignoresUsers;
query.author_id = {$nin: ignoredUsers};
}
const count = await CommentModel.where(query).count();
return count;
};
/**
* Retrieves the count of comments based on the passed in query.
* @param {Object} context graph context
@@ -213,7 +87,7 @@ const getCountByParentIDPersonalized = async (context, {id, excludeIgnored}) =>
* @return {Promise} resolves to the counts of the comments from the
* query
*/
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id, tags}) => {
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id, tags, action_type}) => {
let query = CommentModel.find();
if (ids) {
@@ -236,6 +110,16 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, au
query = query.where({author_id});
}
if (context.user != null && context.user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
query = query.where({
action_counts: {
[action_type.toLowerCase()]: {
$gt: 0,
}
},
});
}
if (tags) {
query = query.find({
'tags.tag.name': {
@@ -249,18 +133,156 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, au
.count();
};
/**
* getStartCursor will retrieve the start cursor based on the sortBy field.
*
* @param {Object} ctx the graph context
* @param {Object} nodes the result set of retrieved comments
* @param {Object} params the params from the client describing the query
*/
const getStartCursor = (ctx, nodes, {cursor, sortBy}) => {
switch (sortBy) {
case 'CREATED_AT':
return nodes.length ? nodes[0].created_at : null;
case 'REPLIES':
// The cursor is the start! This is using numeric pagination.
return cursor != null ? cursor : 0;
}
const SORT_KEY = sortBy.toLowerCase();
if (!ctx.plugins || !ctx.plugins.Sort.Comments || !ctx.plugins.Sort.Comments[SORT_KEY] || !ctx.plugins.Sort.Comments[SORT_KEY].startCursor) {
throw new Error(`unable to sort by ${sortBy}, no plugin was provided to handle this type`);
}
return ctx.plugins.Sort.Comments[SORT_KEY].startCursor(ctx, nodes, {cursor});
};
/**
* getEndCursor will fetch the end cursor based on the desired sortBy parameter.
*
* @param {Object} ctx the graph context
* @param {Object} nodes the result set of retrieved comments
* @param {Object} params the params from the client describing the query
*/
const getEndCursor = (ctx, nodes, {cursor, sortBy}) => {
switch (sortBy) {
case 'CREATED_AT':
return nodes.length ? nodes[nodes.length - 1].created_at : null;
case 'REPLIES':
return nodes.length ? (cursor != null ? cursor : 0) + nodes.length : null;
}
const SORT_KEY = sortBy.toLowerCase();
if (!ctx.plugins || !ctx.plugins.Sort.Comments || !ctx.plugins.Sort.Comments[SORT_KEY] || !ctx.plugins.Sort.Comments[SORT_KEY].endCursor) {
throw new Error(`unable to sort by ${sortBy}, no plugin was provided to handle this type`);
}
return ctx.plugins.Sort.Comments[SORT_KEY].endCursor(ctx, nodes, {cursor});
};
/**
* applySort will add the actual `.sort` and `.skip/.where` clauses to the query
* to apply the desired sort.
*
* @param {Object} ctx the graph context
* @param {Object} query the current mongoose query object
* @param {Object} params the params from the client describing the query
*/
const applySort = (ctx, query, {cursor, sortOrder, sortBy}) => {
switch (sortBy) {
case 'CREATED_AT': {
if (cursor) {
if (sortOrder === 'DESC') {
query = query.where({
created_at: {
$lt: cursor,
},
});
} else {
query = query.where({
created_at: {
$gt: cursor,
},
});
}
}
return query.sort({created_at: sortOrder === 'DESC' ? -1 : 1});
}
case 'REPLIES': {
if (cursor) {
query = query.skip(cursor);
}
return query.sort({reply_count: sortOrder === 'DESC' ? -1 : 1, created_at: sortOrder === 'DESC' ? -1 : 1});
}
}
const SORT_KEY = sortBy.toLowerCase();
if (!ctx.plugins || !ctx.plugins.Sort.Comments || !ctx.plugins.Sort.Comments[SORT_KEY] || !ctx.plugins.Sort.Comments[SORT_KEY].sort) {
throw new Error(`unable to sort by ${sortBy}, no plugin was provided to handle this type`);
}
return ctx.plugins.Sort.Comments[SORT_KEY].sort(ctx, query, {cursor, sortOrder});
};
/**
* executeWithSort will actually retrieve the comments based on the pre-assembled
* query and will compose on top the sort operators necessary to get the desired
* result.
*
* @param {Object} ctx the graph context
* @param {Object} query the current mongoose query object
* @param {Object} params the params from the client describing the query
*/
const executeWithSort = async (ctx, query, {cursor, sortOrder, sortBy, limit}) => {
// Apply the sort to the query.
query = applySort(ctx, query, {cursor, sortOrder, sortBy});
// Apply the limit (if it exists, as it's applied universally).
if (limit) {
query = query.limit(limit + 1);
}
// Fetch the nodes based on the source query.
const nodes = await query.exec();
// The hasNextPage is always handled the same (ask for one more than we need,
// if there is one more, than there is more).
let hasNextPage = false;
if (limit && nodes.length > limit) {
// There was one more than we expected! Set hasNextPage = true and remove
// the last item from the array that we requested.
hasNextPage = true;
nodes.splice(limit, 1);
}
// Use the generator functions below to extract the cursor details based on
// the current sortBy parameter.
return {
startCursor: getStartCursor(ctx, nodes, {cursor, sortOrder, sortBy, limit}),
endCursor: getEndCursor(ctx, nodes, {cursor, sortOrder, sortBy, limit}),
hasNextPage,
nodes,
};
};
/**
* Retrieves comments based on the passed in query that is filtered by the
* current used passed in via the context.
*
* @param {Object} context graph context
* @param {Object} query query terms to apply to the comments query
*/
const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sort, excludeIgnored, tags}) => {
const getCommentsByQuery = async (ctx, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sortOrder, sortBy, excludeIgnored, tags, action_type}) => {
let comments = CommentModel.find();
// Only administrators can search for comments with statuses that are not
// `null`, or `'ACCEPTED'`.
if (user != null && user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS) && statuses) {
if (ctx.user != null && ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS) && statuses && statuses.length > 0) {
comments = comments.where({
status: {
$in: statuses
@@ -274,6 +296,16 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a
});
}
if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
comments = comments.where({
action_counts: {
[action_type.toLowerCase()]: {
$gt: 0,
}
},
});
}
if (ids) {
comments = comments.find({
id: {
@@ -291,7 +323,7 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a
}
// Only let an admin request any user or the current user request themself.
if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === author_id) && author_id != null) {
if (ctx.user && (ctx.user.can(SEARCH_OTHERS_COMMENTS) || ctx.user.id === author_id) && author_id != null) {
comments = comments.where({author_id});
}
@@ -305,151 +337,19 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a
comments = comments.where({parent_id});
}
if (excludeIgnored && user && user.ignoresUsers) {
if (excludeIgnored && ctx.user && ctx.user.ignoresUsers && ctx.user.ignoresUsers.length > 0) {
comments = comments.where({
author_id: {$nin: user.ignoresUsers}
author_id: {$nin: ctx.user.ignoresUsers}
});
}
if (cursor) {
if (sort === 'REVERSE_CHRONOLOGICAL') {
comments = comments.where({
created_at: {
$lt: cursor
}
});
} else {
comments = comments.where({
created_at: {
$gt: cursor
}
});
}
}
let query = comments
.sort({created_at: sort === 'REVERSE_CHRONOLOGICAL' ? -1 : 1});
if (limit) {
query = query.limit(limit + 1);
}
return query.then((nodes) => {
let hasNextPage = false;
if (limit && nodes.length > limit) {
hasNextPage = true;
nodes.splice(limit, 1);
}
return Promise.resolve({
startCursor: nodes.length ? nodes[0].created_at : null,
endCursor: nodes.length ? nodes[nodes.length - 1].created_at : null,
hasNextPage,
nodes,
});
});
return executeWithSort(ctx, comments, {cursor, sortOrder, sortBy, limit});
};
/**
* Gets the recent replies.
* @param {Object} context graph context
* @param {Array<String>} ids ids of parent ids
* @return {Promise} resolves to recent replies
*/
const genRecentReplies = (context, ids) => {
return CommentModel.aggregate([
// get all the replies for the comments in question
{$match: {
parent_id: {
$in: ids
}
}},
// sort these by their created at timestamp, CHRONOLOGICAL'y as these are
// replies
{$sort: {
created_at: 1
}},
// group these replies by their parent_id
{$group: {
_id: '$parent_id',
replies: {
$push: '$$ROOT'
}
}},
// project it so that we only retain the first 3 replies of each parent
// comment
{$project: {
_id: '$_id',
replies: {
$slice: [
'$replies',
0,
3
]
}
}},
{$unwind: '$replies'},
])
.then((replies) => replies.map((reply) => reply.replies))
.then(arrayJoinBy(ids, 'parent_id'));
};
/**
* Gets the recent comments.
* @param {Object} context graph context
* @param {Array<String>} ids ids of asset ids
* @return {Promise} resolves to recent comments from assets
*/
const genRecentComments = (_, ids) => {
return CommentModel.aggregate([
// get all the replies for the comments in question
{$match: {
asset_id: {
$in: ids
}
}},
// sort these by their created at timestamp, CHRONOLOGICAL'y as these are
// replies
{$sort: {
created_at: 1
}},
// group these replies by their parent_id
{$group: {
_id: '$asset_id',
comments: {
$push: '$$ROOT'
}
}},
// project it so that we only retain the first 3 replies of each parent
// comment
{$project: {
_id: '$_id',
comments: {
$slice: [
'$comments',
0,
3
]
}
}},
// Unwind these comments.
{$unwind: '$comments'},
])
.then((replies) => replies.map((reply) => reply.comments))
.then(arrayJoinBy(ids, 'asset_id'));
};
/**
* getComments returns the comments by the id's. Only admins can see non-public comments.
* getComments returns the comments by the id's. Only admins can see non-public
* comments.
*
* @param {Object} context graph context
* @param {Array<String>} ids the comment id's to fetch
* @return {Promise} resolves to the comments
@@ -477,6 +377,7 @@ const getComments = ({user}, ids) => {
/**
* Creates a set of loaders based on a GraphQL context.
*
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
@@ -486,12 +387,6 @@ module.exports = (context) => ({
getByQuery: (query) => getCommentsByQuery(context, query),
getCountByQuery: (query) => getCommentCountByQuery(context, query),
countByAssetID: new SharedCounterDataLoader('Comments.totalCommentCount', ms(CACHE_EXPIRY_COMMENT_COUNT), (ids) => getCountsByAssetID(context, ids)),
countByAssetIDPersonalized: (query) => getCountsByAssetIDPersonalized(context, query),
parentCountByAssetID: new SharedCounterDataLoader('Comments.countByAssetID', ms(CACHE_EXPIRY_COMMENT_COUNT), (ids) => getParentCountsByAssetID(context, ids)),
parentCountByAssetIDPersonalized: (query) => getParentCountByAssetIDPersonalized(context, query),
countByParentID: new SharedCounterDataLoader('Comments.countByParentID', ms(CACHE_EXPIRY_COMMENT_COUNT), (ids) => getCountsByParentID(context, ids)),
countByParentIDPersonalized: (query) => getCountByParentIDPersonalized(context, query),
genRecentReplies: new DataLoader((ids) => genRecentReplies(context, ids)),
genRecentComments: new DataLoader((ids) => genRecentComments(context, ids))
parentCountByAssetID: new SharedCounterDataLoader('Comments.countByAssetID', ms(CACHE_EXPIRY_COMMENT_COUNT), (ids) => getParentCountsByAssetID(context, ids))
}
});
+12 -12
View File
@@ -54,13 +54,13 @@ const getAssetActivityMetrics = ({loaders: {Assets}}, {from, to, limit}) => {
/**
* Returns a list of assets with action metadata included on the models.
*/
const getAssetMetrics = async ({loaders: {Metrics, Assets, Comments}}, {from, to, sort, limit}) => {
const getAssetMetrics = async ({loaders: {Metrics, Assets, Comments}}, {from, to, sortBy, limit}) => {
// Get the recent actions.
let actionSummaries = await Metrics.getRecentActions.load({from, to});
let commentMetrics = actionSummaries.reduce((acc, {item_id, action_type, count}) => {
if (action_type !== sort) {
if (action_type !== sortBy) {
return acc;
}
@@ -94,9 +94,9 @@ const getAssetMetrics = async ({loaders: {Metrics, Assets, Comments}}, {from, to
return {action_summaries, id: asset_id};
})
.filter((asset) => {
let contextActionSummary = asset.action_summaries.find((({action_type}) => action_type === sort));
let contextActionSummary = asset.action_summaries.find((({action_type}) => action_type === sortBy));
if (contextActionSummary === null || contextActionSummary.actionCount === 0) {
return false;
}
@@ -108,8 +108,8 @@ const getAssetMetrics = async ({loaders: {Metrics, Assets, Comments}}, {from, to
// if the action summary does not exist on the object, that it is less
// prefered over the one that does have it.
.sort((a, b) => {
let aActionSummary = a.action_summaries.find((({action_type}) => action_type === sort));
let bActionSummary = b.action_summaries.find((({action_type}) => action_type === sort));
let aActionSummary = a.action_summaries.find((({action_type}) => action_type === sortBy));
let bActionSummary = b.action_summaries.find((({action_type}) => action_type === sortBy));
// Both of them had an actionCount, hence we can determine that we could
// compare the actual values directly.
@@ -144,15 +144,15 @@ const getAssetMetrics = async ({loaders: {Metrics, Assets, Comments}}, {from, to
* Returns a list of comments that are retrieved based on most activity within
* the indicated time range.
*/
const getCommentMetrics = async ({loaders: {Metrics, Comments}}, {from, to, sort, limit}) => {
const getCommentMetrics = async ({loaders: {Metrics, Comments}}, {from, to, sortBy, limit}) => {
let commentActionSummaries = {};
let actionSummaries = await Metrics.getRecentActions.load({from, to});
actionSummaries.sort((a, b) => {
let aActionSummary = a.action_type === sort ? a : null;
let bActionSummary = b.action_type === sort ? b : null;
let aActionSummary = a.action_type === sortBy ? a : null;
let bActionSummary = b.action_type === sortBy ? b : null;
// If either a or b don't have this action type, then one of them will
// automatically win.
@@ -178,7 +178,7 @@ const getCommentMetrics = async ({loaders: {Metrics, Comments}}, {from, to, sort
// Grab the comment id's for comment where they have at least one of the
// actions being sorted by.
let commentIDs = Object.keys(commentActionSummaries).filter((item_id) => {
let contextActionSummary = commentActionSummaries[item_id].find(({action_type}) => action_type === sort);
let contextActionSummary = commentActionSummaries[item_id].find(({action_type}) => action_type === sortBy);
if (contextActionSummary == null) {
return false;
}
@@ -247,11 +247,11 @@ module.exports = (context) => ({
cacheKeyFn: objectCacheKeyFn('from', 'to')
}),
Assets: {
get: ({from, to, sort, limit}) => getAssetMetrics(context, {from, to, sort, limit}),
get: ({from, to, sortBy, limit}) => getAssetMetrics(context, {from, to, sortBy, limit}),
getActivity: ({from, to, limit}) => getAssetActivityMetrics(context, {from, to, limit}),
},
Comments: {
get: ({from, to, sort, limit}) => getCommentMetrics(context, {from, to, sort, limit}),
get: ({from, to, sortBy, limit}) => getCommentMetrics(context, {from, to, sortBy, limit}),
}
}
});
+3 -3
View File
@@ -15,7 +15,7 @@ const genUserByIDs = (context, ids) => UsersService
* @param {Object} context graph context
* @param {Object} query query terms to apply to the users query
*/
const getUsersByQuery = ({user}, {ids, limit, cursor, statuses = null, sort}) => {
const getUsersByQuery = ({user}, {ids, limit, cursor, statuses = null, sortOrder}) => {
let users = UserModel.find();
@@ -36,7 +36,7 @@ const getUsersByQuery = ({user}, {ids, limit, cursor, statuses = null, sort}) =>
}
if (cursor) {
if (sort === 'REVERSE_CHRONOLOGICAL') {
if (sortOrder === 'DESC') {
users = users.where({
created_at: {
$lt: cursor
@@ -52,7 +52,7 @@ const getUsersByQuery = ({user}, {ids, limit, cursor, statuses = null, sort}) =>
}
return users
.sort({created_at: sort === 'REVERSE_CHRONOLOGICAL' ? -1 : 1})
.sort({created_at: sortOrder === 'DESC' ? -1 : 1})
.limit(limit);
};
+2 -6
View File
@@ -1,4 +1,3 @@
const ActionModel = require('../../models/action');
const ActionsService = require('../../services/actions');
const UsersService = require('../../services/users');
const errors = require('../../errors');
@@ -23,7 +22,7 @@ const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id,
}
}
let action = await ActionsService.insertUserAction({
let action = await ActionsService.create({
item_id,
item_type,
user_id: user.id,
@@ -52,10 +51,7 @@ const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id,
* @return {Promise} resolves to the deleted action, or null if not found.
*/
const deleteAction = ({user}, {id}) => {
return ActionModel.findOneAndRemove({
id,
user_id: user.id
});
return ActionsService.delete({id, user_id: user.id});
};
module.exports = (context) => {
+3 -7
View File
@@ -174,9 +174,7 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu
// perform these increments in the event that we do have a new comment that
// is approved or without a comment.
if (status === 'NONE' || status === 'APPROVED') {
if (parent_id != null) {
Comments.countByParentID.incr(parent_id);
} else {
if (parent_id === null) {
Comments.parentCountByAssetID.incr(asset_id);
}
Comments.countByAssetID.incr(asset_id);
@@ -309,7 +307,7 @@ const createPublicComment = async (context, commentInput) => {
// TODO: this is kind of fragile, we should refactor this to resolve
// all these const's that we're using like 'COMMENTS', 'FLAG' to be
// defined in a checkable schema.
await ActionsService.insertUserAction({
await ActionsService.create({
item_id: comment.id,
item_type: 'COMMENTS',
action_type: 'FLAG',
@@ -338,9 +336,7 @@ const setStatus = async ({user, loaders: {Comments}, pubsub}, {id, status}) => {
// be nice if we could decrement the counters here, but that would result
// in us having to know the initial state of the comment, which would
// require another database query.
if (comment.parent_id != null) {
Comments.countByParentID.clear(comment.parent_id);
} else {
if (comment.parent_id === null) {
Comments.parentCountByAssetID.clear(comment.asset_id);
}
+47 -39
View File
@@ -1,55 +1,63 @@
const {decorateWithTags} = require('./util');
const {
SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS,
} = require('../../perms/constants');
const Asset = {
recentComments({id}, _, {loaders: {Comments}}) {
return Comments.genRecentComments.load(id);
},
async comment({id}, {id: commentId}, {loaders: {Comments}, user}) {
const statuses = user && user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS)
? ['NONE', 'ACCEPTED', 'PREMOD', 'REJECTED']
: ['NONE', 'ACCEPTED'];
async comment({id}, {id: commentId}, {loaders: {Comments}}) {
const comments = await Comments.getByQuery({
asset_id: id,
ids: commentId,
statuses,
});
return comments.nodes[0];
},
comments({id}, {sort, limit, deep, excludeIgnored, tags}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id: id,
sort,
limit,
parent_id: deep ? undefined : null,
tags,
excludeIgnored,
});
},
commentCount({id, commentCount}, {excludeIgnored, tags}, {user, loaders: {Comments}}) {
// TODO: remove
if ((user && excludeIgnored) || tags) {
return Comments.parentCountByAssetIDPersonalized({assetId: id, excludeIgnored, tags});
// Load the comment from the database.
const comment = await Comments.get.load(commentId);
if (!comment) {
return null;
}
// If the comment asset mismatches, then don't return it!
if (comment.asset_id !== id) {
return null;
}
return comment;
},
comments({id}, {query, deep}, {loaders: {Comments}}) {
if (!deep) {
query.parent_id = null;
}
return Comments.getByQuery(query);
},
commentCount({id, commentCount}, {tags}, {loaders: {Comments}}) {
if (commentCount != null) {
return commentCount;
}
// If we are filtering by a tag.
if (tags && tags.length > 0) {
// Then count the comments with those tags.
return Comments.getCountByQuery({
tags,
asset_id: id,
parent_id: null,
statuses: ['NONE', 'ACCEPTED'],
});
}
return Comments.parentCountByAssetID.load(id);
},
totalCommentCount({id, totalCommentCount}, {excludeIgnored, tags}, {user, loaders: {Comments}}) {
// TODO: remove
if ((user && excludeIgnored) || tags) {
return Comments.countByAssetIDPersonalized({assetId: id, excludeIgnored, tags});
}
totalCommentCount({id, totalCommentCount}, {tags}, {loaders: {Comments}}) {
if (totalCommentCount != null) {
return totalCommentCount;
}
// If we are filtering by a tag.
if (tags && tags.length > 0) {
// Then count the comments with those tags.
return Comments.getCountByQuery({
tags,
asset_id: id,
statuses: ['NONE', 'ACCEPTED'],
});
}
return Comments.countByAssetID.load(id);
},
async settings({settings = null}, _, {loaders: {Settings}}) {
+20 -21
View File
@@ -14,33 +14,32 @@ const Comment = {
user({author_id}, _, {loaders: {Users}}) {
return Users.getByID.load(author_id);
},
recentReplies({id}, _, {loaders: {Comments}}) {
return Comments.genRecentReplies.load(id);
},
replies({id, asset_id}, {sort, limit, excludeIgnored}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id,
parent_id: id,
sort,
limit,
excludeIgnored,
});
},
replyCount({id}, {excludeIgnored}, {user, loaders: {Comments}}) {
replies({id, asset_id, reply_count}, {query}, {loaders: {Comments}}) {
// TODO: remove
if (user && excludeIgnored) {
return Comments.countByParentIDPersonalized({id, excludeIgnored});
// Don't bother looking up replies if there aren't any there!
if (reply_count === 0) {
return {
nodes: [],
hasNextPage: false,
};
}
return Comments.countByParentID.load(id);
query.asset_id = asset_id;
query.parent_id = id;
return Comments.getByQuery(query);
},
replyCount({reply_count}) {
// A simple remap from the underlying database model to the graph model.
return reply_count;
},
actions({id}, _, {user, loaders: {Actions}}) {
if (user && user.can('SEARCH_ACTIONS')) {
return Actions.getByID.load(id);
if (!user || !user.can('SEARCH_ACTIONS')) {
return null;
}
return null;
return Actions.getByID.load(id);
},
action_summaries({id, action_summaries}, _, {loaders: {Actions}}) {
if (action_summaries) {
+4 -2
View File
@@ -2,9 +2,11 @@ const {SEARCH_OTHER_USERS} = require('../../perms/constants');
const CommentStatusHistory = {
assigned_by({assigned_by}, _, {user, loaders: {Users}}) {
if (user && user.can(SEARCH_OTHER_USERS) && assigned_by != null) {
return Users.getByID.load(assigned_by);
if (!user || !user.can(SEARCH_OTHER_USERS) || assigned_by == null) {
return null;
}
return Users.getByID.load(assigned_by);
}
};
+35
View File
@@ -0,0 +1,35 @@
const GraphQLScalarType = require('graphql').GraphQLScalarType;
const Kind = require('graphql/language').Kind;
module.exports = new GraphQLScalarType({
name: 'Cursor',
description: 'Cursor represents a paginating cursor.',
serialize(value) {
if (value instanceof Date) {
return value.toISOString();
}
return value;
},
parseValue(value) {
if (typeof value === 'string') {
return new Date(value);
}
return value;
},
parseLiteral(ast) {
switch (ast.kind) {
case Kind.STRING:
// This handles an empty string.
if (ast.value && ast.value.length === 0) {
return null;
}
return new Date(ast.value);
default:
return ast.value;
}
}
});
+4 -2
View File
@@ -8,9 +8,11 @@ const FlagAction = {
return group_id;
},
user({user_id}, _, {loaders: {Users}}) {
if (user_id) {
return Users.getByID.load(user_id);
if (!user_id) {
return null;
}
return Users.getByID.load(user_id);
},
};
+4 -2
View File
@@ -5,8 +5,9 @@ const ActionSummary = require('./action_summary');
const Action = require('./action');
const AssetActionSummary = require('./asset_action_summary');
const Asset = require('./asset');
const Comment = require('./comment');
const CommentStatusHistory = require('./comment_status_history');
const Comment = require('./comment');
const Cursor = require('./cursor');
const Date = require('./date');
const FlagActionSummary = require('./flag_action_summary');
const FlagAction = require('./flag_action');
@@ -31,8 +32,9 @@ let resolvers = {
Action,
AssetActionSummary,
Asset,
Comment,
CommentStatusHistory,
Comment,
Cursor,
Date,
FlagActionSummary,
FlagAction,
+11 -23
View File
@@ -26,13 +26,7 @@ const RootQuery = {
// This endpoint is used for loading moderation queues, so hide it in the
// event that we aren't an admin.
async comments(_, {query}, {user, loaders: {Comments, Actions}}) {
let {action_type} = query;
if (user != null && user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
}
async comments(_, {query}, {loaders: {Comments}}) {
return Comments.getByQuery(query);
},
@@ -40,19 +34,13 @@ const RootQuery = {
return Comments.get.load(id);
},
async commentCount(_, {query}, {user, loaders: {Actions, Comments, Assets}}) {
async commentCount(_, {query}, {user, loaders: {Comments, Assets}}) {
if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) {
return null;
}
const {action_type, asset_url} = query;
if (action_type) {
query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
}
if (asset_url) {
const {asset_url, asset_id} = query;
if ((!asset_id || asset_id.length === 0) && asset_url && asset_url.length > 0) {
let asset = await Assets.findByUrl(asset_url);
if (asset) {
query.asset_id = asset.id;
@@ -62,24 +50,25 @@ const RootQuery = {
return Comments.getCountByQuery(query);
},
assetMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Assets}}}) {
assetMetrics(_, query, {user, loaders: {Metrics: {Assets}}}) {
if (user == null || !user.can(SEARCH_ASSETS)) {
return null;
}
if (sort === 'ACTIVITY') {
return Assets.getActivity({from, to, limit});
const {sortBy} = query;
if (sortBy === 'ACTIVITY') {
return Assets.getActivity(query);
}
return Assets.get({from, to, sort, limit});
return Assets.get(query);
},
commentMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Comments}}}) {
commentMetrics(_, query, {user, loaders: {Metrics: {Comments}}}) {
if (user == null || !user.can(SEARCH_COMMENT_METRICS)) {
return null;
}
return Comments.get({from, to, sort, limit});
return Comments.get(query);
},
// This returns the current user, ensure that if we aren't logged in, we
@@ -109,7 +98,6 @@ const RootQuery = {
}
const {action_type} = query;
if (action_type) {
query.ids = await Actions.getByTypes({action_type, item_type: 'USERS'});
query.statuses = ['PENDING'];
+1 -1
View File
@@ -45,7 +45,7 @@ const User = {
return null;
},
tokens({id, tokens}, args, {user}) {
if (!user || ((user.id !== id) && !user.can(LIST_OWN_TOKENS))) {
if (!user || ((user.id !== id) && !user.can(LIST_OWN_TOKENS))) {
return null;
}
+62 -40
View File
@@ -5,6 +5,9 @@
# Date represented as an ISO8601 string.
scalar Date
# Cursor represents a paginating cursor.
scalar Cursor
################################################################################
## Reliability
################################################################################
@@ -17,7 +20,7 @@ type Reliability {
# `null` if the reliability cannot be determined.
flagger: Boolean
# commenter will be `true` when the commenter is reliable, `false` if not, or
# Commenter will be `true` when the commenter is reliable, `false` if not, or
# `null` if the reliability cannot be determined.
commenter: Boolean
}
@@ -126,10 +129,10 @@ input UsersQuery {
limit: Int = 10
# Skip results from the last created_at timestamp.
cursor: Date
cursor: Cursor
# Sort the results by created_at.
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
sortOrder: SORT_ORDER = DESC
}
# AssetsQuery allows teh ability to query assets by specific fields
@@ -208,7 +211,7 @@ enum COMMENT_STATUS {
PREMOD
}
# The types of action there are as enum's.
# The types of action there are as enums.
enum ACTION_TYPE {
# Represents a FlagAction.
@@ -241,10 +244,14 @@ input CommentsQuery {
limit: Int = 10
# Skip results from the last created_at timestamp.
cursor: Date
cursor: Cursor
# Sort the results by created_at.
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
# Sort the results by from largest first.
sortOrder: SORT_ORDER = DESC
# The order to sort the comments by, sorting by default the created at
# timestamp.
sortBy: SORT_COMMENTS_BY = CREATED_AT
# Filter by a specific tag name.
tags: [String!]
@@ -253,6 +260,22 @@ input CommentsQuery {
excludeIgnored: Boolean
}
input RepliesQuery {
# Sort the results by from smallest first.
sortOrder: SORT_ORDER = ASC
# The order to sort the comments by, sorting by default the created at
# timestamp.
sortBy: SORT_COMMENTS_BY = CREATED_AT
# Limit the number of results to be returned.
limit: Int = 3
# Exclude comments ignored by the requesting user
excludeIgnored: Boolean
}
# CommentCountQuery allows the ability to query comment counts by specific
# methods.
input CommentCountQuery {
@@ -310,14 +333,11 @@ type Comment {
# the user who authored the comment.
user: User
# the recent replies made against this comment.
recentReplies: [Comment!]
# the replies that were made to the comment.
replies(sort: SORT_ORDER = CHRONOLOGICAL, limit: Int = 3, excludeIgnored: Boolean): CommentConnection!
replies(query: RepliesQuery = {}): CommentConnection!
# The count of replies on a comment.
replyCount(excludeIgnored: Boolean): Int
replyCount: Int
# Actions completed on the parent. Requires the `ADMIN` role.
actions: [Action]
@@ -351,10 +371,10 @@ type CommentConnection {
hasNextPage: Boolean!
# Cursor of first comment in subset.
startCursor: Date
startCursor: Cursor
# Cursor of last comment in subset.
endCursor: Date
endCursor: Cursor
# Subset of comments.
nodes: [Comment!]!
@@ -570,28 +590,18 @@ type Asset {
# The URL that the asset is located on.
url: String
# Returns recent comments
recentComments: [Comment!]
# The comments that are attached to the asset.
# If `deep` is true, it will return comments of all depths,
# otherwise only top-level comments are returned.
comments(
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL,
limit: Int = 10,
excludeIgnored: Boolean,
tags: [String!]
deep: Boolean,
): CommentConnection!
# The comments that are attached to the asset. When `deep` is true, the
# comments returned will be at all depths.
comments(query: CommentsQuery = {}, deep: Boolean = false): CommentConnection!
# A Comment from the Asset by comment's ID
comment(id: ID!): Comment
# The count of top level comments on the asset.
commentCount(excludeIgnored: Boolean, tags: [String!]): Int
commentCount(tags: [String!]): Int
# The total count of all comments made on the asset.
totalCommentCount(excludeIgnored: Boolean, tags: [String!]): Int
totalCommentCount(tags: [String!]): Int
# The settings (rectified with the global settings) that should be applied to
# this asset.
@@ -656,10 +666,22 @@ type ValidationUserError implements UserError {
enum SORT_ORDER {
# newest to oldest order.
REVERSE_CHRONOLOGICAL
DESC
# oldest to newer order.
CHRONOLOGICAL
ASC
}
# SORT_COMMENTS_BY selects the means for which comments are ordered when
# sorting.
enum SORT_COMMENTS_BY {
# Comments will be sorted by their created at date.
CREATED_AT
# Comments will be sorted by their immediate reply count (replies to the comment
# in question only, not including descendants).
REPLIES
}
# All queries that can be executed.
@@ -716,11 +738,11 @@ type RootQuery {
# Asset metrics related to user actions are saturated into the assets
# returned. Parameters `from` and `to` are related to the action created_at field.
assetMetrics(from: Date!, to: Date!, sort: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!]
assetMetrics(from: Date!, to: Date!, sortBy: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!]
# Comment metrics related to user actions are saturated into the comments
# returned. Parameters `from` and `to` are related to the action created_at field.
commentMetrics(from: Date!, to: Date!, sort: ACTION_TYPE!, limit: Int = 10): [Comment!]
commentMetrics(from: Date!, to: Date!, sortBy: ACTION_TYPE!, limit: Int = 10): [Comment!]
}
################################################################################
@@ -922,19 +944,19 @@ input ModifyTagInput {
# Response to the addTag or removeTag mutations.
type ModifyTagResponse implements Response {
# An array of errors relating to the mutation that occured.
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# Response to ignoreUser mutation
type IgnoreUserResponse implements Response {
# An array of errors relating to the mutation that occured.
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# Response to stopIgnoringUser mutation
type StopIgnoringUserResponse implements Response {
# An array of errors relating to the mutation that occured.
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
@@ -945,13 +967,13 @@ input EditCommentInput {
body: String!
}
# EditCommentResponse contains the updated comment and any errors that occured.
# EditCommentResponse contains the updated comment and any errors that occurred.
type EditCommentResponse implements Response {
# The edited comment.
comment: Comment
# An array of errors relating to the mutation that occured.
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
@@ -968,7 +990,7 @@ type CreateTokenResponse implements Response {
# Token is the Token that was created, or null if it failed.
token: Token
# An array of errors relating to the mutation that occured.
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
@@ -982,7 +1004,7 @@ input RevokeTokenInput {
# RevokeTokenResponse contains the errors related to revoking a token.
type RevokeTokenResponse implements Response {
# An array of errors relating to the mutation that occured.
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
+1 -1
View File
@@ -72,7 +72,7 @@ AssetSchema.index({
subsection: 'text',
author: 'text'
}, {
background: true
background: true,
});
/**
+38 -2
View File
@@ -55,7 +55,7 @@ const CommentSchema = new Schema({
body: {
type: String,
required: [true, 'The body is required.'],
minlength: 2
minlength: 2,
},
body_history: [BodyHistoryItemSchema],
asset_id: String,
@@ -66,15 +66,29 @@ const CommentSchema = new Schema({
enum: COMMENT_STATUS,
default: 'NONE'
},
// parent_id is the id of the parent comment (null if there is none).
parent_id: String,
// The number of replies to this comment directly.
reply_count: {
type: Number,
default: 0,
},
// Counts to store related to actions taken on the given comment.
action_counts: {
default: {},
type: Object,
},
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
// Additional metadata stored on the field.
metadata: {
default: {},
type: Object
type: Object,
}
}, {
timestamps: {
@@ -94,10 +108,32 @@ CommentSchema.index({
background: false
});
// Add an index that is optimized for sorting based on the action count data.
CommentSchema.index({
'created_at': 1,
'action_counts': 1,
}, {
background: true,
});
// Add an index that is optimized for sorting based on the created_at timestamp
// but also good at locating comments that have a specific asset id.
CommentSchema.index({
'asset_id': 1,
'created_at': 1,
}, {
background: true,
});
CommentSchema.virtual('edited').get(function() {
return this.body_history.length > 1;
});
// Visable is true when the comment is visible to the public.
CommentSchema.virtual('visible').get(function() {
return ['ACCEPTED', 'NONE'].includes(this.status);
});
// Comment model.
const Comment = mongoose.model('Comment', CommentSchema);
-54
View File
@@ -1,54 +0,0 @@
require('babel-core/register');
let E2E_REPORT_PATH = './test/e2e/reports';
if (process.env.E2E_REPORT_PATH && process.env.E2E_REPORT_PATH.length > 0) {
E2E_REPORT_PATH = process.env.E2E_REPORT_PATH;
}
module.exports = {
'src_folders': './test/e2e/tests',
'output_folder': E2E_REPORT_PATH,
'page_objects_path': './test/e2e/pages',
'globals_path': './test/e2e/globals',
'custom_commands_path' : '',
'custom_assertions_path' : '',
'selenium': {
'start_process': true,
'server_path': 'node_modules/selenium-standalone/.selenium/selenium-server/2.53.1-server.jar',
'log_path': E2E_REPORT_PATH,
'host': '127.0.0.1',
'port': 6666,
'cli_args': {
'webdriver.chrome.driver': 'node_modules/selenium-standalone/.selenium/chromedriver/2.25-x64-chromedriver'
}
},
'test_settings': {
'default': {
'launch_url' : 'http://localhost:3011',
'selenium_port': 6666,
'selenium_host': 'localhost',
'silent': true,
'desiredCapabilities': {
'browserName': 'chrome',
'javascriptEnabled': true,
'acceptSslCerts': true,
'webStorageEnabled': true,
'databaseEnabled': true,
'applicationCacheEnabled': false,
'nativeEvents': true
},
'screenshots' : {
'enabled': true,
'on_failure': true,
'on_error': true,
'path': E2E_REPORT_PATH
},
'exclude': [
'./tests/e2e/tests/EmbedStreamTests.js'
]
},
'integration': {
'launch_url': 'http://localhost:3011'
}
}
};
+116 -130
View File
@@ -6,7 +6,7 @@
"scripts": {
"postinstall": "./bin/cli plugins reconcile --skip-remote",
"start": "./bin/cli serve -j -w",
"dev-start": "nodemon -w . -w bin/cli -w bin/cli-serve --config .nodemon.json --exec \"WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js && ./bin/cli -c .env serve -j -w\"",
"dev-start": "nodemon -w . -w bin/cli -w bin/cli-serve --config .nodemon.json --exec \"yarn generate-introspection && ./bin/cli -c .env serve -j -w\"",
"prebuild": "yarn generate-introspection",
"build": "WEBPACK=TRUE NODE_ENV=production webpack -p --config webpack.config.js --bail",
"prebuild-watch": "yarn generate-introspection",
@@ -15,10 +15,6 @@
"lint-fix": "eslint bin/* . --fix",
"test": "TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
"test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec",
"pree2e": "NODE_ENV=test TALK_PORT=3011 scripts/pree2e.sh",
"e2e": "NODE_ENV=test nightwatch",
"poste2e": "NODE_ENV=test scripts/poste2e.sh",
"embed-start": "NODE_ENV=development yarn build && ./bin/cli serve --jobs",
"heroku-postbuild": "./bin/cli plugins reconcile && yarn build",
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js"
},
@@ -59,89 +55,12 @@
"homepage": "https://github.com/coralproject/talk#readme",
"dependencies": {
"accepts": "^1.3.3",
"app-module-path": "^2.2.0",
"async": "^2.5.0",
"bcryptjs": "^2.4.3",
"body-parser": "^1.17.1",
"bowser": "^1.7.0",
"cli-table": "^0.3.1",
"clipboard": "^1.7.1",
"colors": "^1.1.2",
"commander": "^2.9.0",
"compression": "^1.6.2",
"connect-redis": "^3.1.0",
"cookie-parser": "^1.4.3",
"cross-spawn": "^5.1.0",
"csurf": "^1.9.0",
"dataloader": "^1.3.0",
"debug": "^2.6.3",
"dotenv": "^4.0.0",
"ejs": "^2.5.6",
"env-rewrite": "^1.0.2",
"eventemitter2": "^4.1.2",
"express": "^4.15.2",
"express-session": "^1.15.1",
"file-loader": "^0.11.2",
"form-data": "^2.1.2",
"fs-extra": "^3.0.1",
"gql-merge": "^0.0.4",
"graphql": "^0.9.1",
"graphql-errors": "^2.1.0",
"graphql-redis-subscriptions": "^1.1.5",
"graphql-server-express": "^0.6.0",
"graphql-subscriptions": "^0.4.3",
"graphql-tools": "^0.10.1",
"helmet": "^3.5.0",
"immutability-helper": "^2.2.0",
"inquirer": "^3.2.1",
"joi": "^10.4.1",
"jsonwebtoken": "^7.3.0",
"jwt-decode": "^2.2.0",
"kue": "^0.11.5",
"linkify-it": "^2.0.3",
"lodash": "^4.16.6",
"marked": "^0.3.6",
"metascraper": "^1.0.6",
"minimist": "^1.2.0",
"mongoose": "^4.9.8",
"morgan": "^1.8.1",
"ms": "^2.0.0",
"murmurhash-js": "^1.0.0",
"natural": "^0.5.0",
"node-emoji": "^1.5.1",
"node-fetch": "^1.6.3",
"nodemailer": "^2.6.4",
"passport": "^0.3.2",
"passport-jwt": "^2.2.1",
"passport-local": "^1.0.0",
"prop-types": "^15.5.10",
"query-strings": "^0.0.1",
"react-apollo": "^1.4.12",
"react-input-autosize": "^1.1.4",
"react-recaptcha": "^2.2.6",
"react-toastify": "^1.5.0",
"react-transition-group": "^1.1.3",
"recompose": "^0.23.1",
"redis": "^2.7.1",
"resolve": "^1.3.2",
"semver": "^5.3.0",
"simplemde": "^1.11.2",
"smoothscroll-polyfill": "^0.3.5",
"snake-case": "^2.1.0",
"subscriptions-transport-ws": "^0.7.2",
"timekeeper": "^1.0.0",
"url-search-params": "^0.9.0",
"uuid": "^3.0.1",
"yaml-loader": "^0.4.0",
"yamljs": "^0.2.10"
},
"devDependencies": {
"apollo-client": "^1.9.1",
"app-module-path": "^2.2.0",
"autoprefixer": "^6.5.2",
"babel-cli": "^6.24.0",
"babel-core": "^6.24.0",
"babel-eslint": "^7.2.1",
"babel-jest": "^19.0.0",
"babel-loader": "^6.4.1",
"babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
@@ -154,16 +73,120 @@
"babel-polyfill": "^6.23.0",
"babel-preset-es2015": "^6.24.0",
"babel-preset-react": "^6.23.0",
"babel-preset-stage-0": "^6.16.0",
"bcryptjs": "^2.4.3",
"body-parser": "^1.17.1",
"bowser": "^1.7.0",
"cli-table": "^0.3.1",
"clipboard": "^1.7.1",
"colors": "^1.1.2",
"commander": "^2.11.0",
"common-tags": "^1.4.0",
"compression": "^1.7.0",
"compression-webpack-plugin": "^0.4.0",
"cookie-parser": "^1.4.3",
"copy-webpack-plugin": "^4.0.0",
"cross-spawn": "^5.1.0",
"css-loader": "^0.27.3",
"dataloader": "^1.3.0",
"debug": "^3.0.0",
"dialog-polyfill": "^0.4.4",
"dotenv": "^4.0.0",
"ejs": "^2.5.7",
"env-rewrite": "^1.0.2",
"eventemitter2": "^4.1.2",
"exports-loader": "^0.6.4",
"express": "^4.15.4",
"file-loader": "^0.11.2",
"form-data": "^2.2.0",
"fs-extra": "^4.0.1",
"gql-merge": "^0.0.4",
"graphql": "^0.9.1",
"graphql-docs": "^0.2.0",
"graphql-errors": "^2.1.0",
"graphql-redis-subscriptions": "^1.1.5",
"graphql-server-express": "^0.6.0",
"graphql-subscriptions": "^0.4.3",
"graphql-tag": "^1.2.3",
"graphql-tools": "^0.10.1",
"hammerjs": "^2.0.8",
"helmet": "^3.8.1",
"history": "^3.0.0",
"immutability-helper": "^2.2.0",
"imports-loader": "^0.7.1",
"inquirer": "^3.2.2",
"istanbul": "^1.1.0-alpha.1",
"joi": "^10.6.0",
"json-loader": "^0.5.4",
"jsonwebtoken": "^7.4.3",
"jwt-decode": "^2.2.0",
"keymaster": "^1.6.2",
"kue": "^0.11.6",
"license-webpack-plugin": "^1.0.0",
"linkify-it": "^2.0.3",
"lodash": "^4.16.6",
"marked": "^0.3.6",
"material-design-lite": "^1.2.1",
"metascraper": "^1.0.7",
"minimist": "^1.2.0",
"mongoose": "^4.11.7",
"morgan": "^1.8.2",
"ms": "^2.0.0",
"murmurhash-js": "^1.0.0",
"natural": "^0.5.4",
"node-emoji": "^1.8.1",
"node-fetch": "^1.7.2",
"nodemailer": "^2.6.4",
"passport": "^0.4.0",
"passport-jwt": "^3.0.0",
"passport-local": "^1.0.0",
"pluralize": "^7.0.0",
"postcss-loader": "^1.3.3",
"postcss-modules": "^0.5.2",
"postcss-smart-import": "^0.5.1",
"precss": "^1.4.0",
"prop-types": "^15.5.10",
"pym.js": "^1.1.1",
"query-strings": "^0.0.1",
"react": "^15.4.2",
"react-apollo": "^1.4.12",
"react-dom": "^15.4.2",
"react-highlight-words": "^0.6.0",
"react-input-autosize": "^1.1.4",
"react-linkify": "^0.1.3",
"react-mdl": "^1.7.2",
"react-mdl-selectfield": "^0.2.0",
"react-recaptcha": "^2.2.6",
"react-redux": "^4.4.5",
"react-router": "^3.0.0",
"react-tagsinput": "^3.17.0",
"react-toastify": "^1.5.0",
"react-transition-group": "^1.1.3",
"recompose": "^0.23.1",
"redis": "^2.8.0",
"redux": "^3.6.0",
"redux-thunk": "^2.1.0",
"resolve": "^1.4.0",
"semver": "^5.4.1",
"simplemde": "^1.11.2",
"smoothscroll-polyfill": "^0.3.5",
"snake-case": "^2.1.0",
"style-loader": "^0.16.0",
"subscriptions-transport-ws": "^0.7.2",
"timeago.js": "^2.0.3",
"timekeeper": "^1.0.0",
"url-loader": "^0.5.9",
"url-search-params": "^0.9.0",
"uuid": "^3.1.0",
"webpack": "^2.3.1",
"webpack-sources": "^1.0.1",
"yaml-loader": "^0.4.0",
"yamljs": "^0.2.10"
},
"devDependencies": {
"chai": "^3.5.0",
"chai-as-promised": "^6.0.0",
"chai-http": "^3.0.0",
"common-tags": "^1.4.0",
"compression-webpack-plugin": "^0.4.0",
"copy-webpack-plugin": "^4.0.0",
"css-loader": "^0.27.3",
"dialog-polyfill": "^0.4.4",
"enzyme": "^2.6.0",
"enzyme": "^2.9.1",
"eslint": "^3.12.1",
"eslint-config-postcss": "^2.0.2",
"eslint-config-standard": "^6.2.1",
@@ -174,50 +197,13 @@
"eslint-plugin-promise": "^3.3.1",
"eslint-plugin-react": "^6.6.0",
"eslint-plugin-standard": "^2.0.1",
"exports-loader": "^0.6.4",
"fetch-mock": "^5.5.0",
"graphql-docs": "^0.2.0",
"graphql-tag": "^1.2.3",
"hammerjs": "^2.0.8",
"history": "^3.0.0",
"ignore-styles": "^5.0.1",
"imports-loader": "^0.7.1",
"istanbul": "^1.1.0-alpha.1",
"jsdom": "^9.8.3",
"json-loader": "^0.5.4",
"keymaster": "^1.6.2",
"license-webpack-plugin": "^0.4.2",
"material-design-lite": "^1.2.1",
"mocha": "^3.1.2",
"mocha-junit-reporter": "^1.12.1",
"nightwatch": "^0.9.11",
"nodemon": "^1.11.0",
"postcss-loader": "^1.3.3",
"postcss-modules": "^0.5.2",
"postcss-smart-import": "^0.5.1",
"pre-git": "^3.10.0",
"precss": "^1.4.0",
"pym.js": "^1.1.1",
"react": "^15.4.2",
"react-addons-test-utils": "^15.4.2",
"react-dom": "^15.4.2",
"react-highlight-words": "^0.6.0",
"react-linkify": "^0.1.3",
"react-mdl": "^1.7.2",
"react-mdl-selectfield": "^0.2.0",
"react-redux": "^4.4.5",
"react-router": "^3.0.0",
"react-tagsinput": "^3.17.0",
"redux": "^3.6.0",
"redux-mock-store": "^1.2.1",
"redux-thunk": "^2.1.0",
"regenerator": "^0.8.46",
"selenium-standalone": "^5.11.2",
"style-loader": "^0.16.0",
"supertest": "^2.0.1",
"timeago.js": "^2.0.3",
"url-loader": "^0.5.9",
"webpack": "^2.3.1"
"sinon": "^3.2.1",
"sinon-chai": "^2.13.0",
"supertest": "^2.0.1"
},
"engines": {
"node": "^8"
+1
View File
@@ -0,0 +1 @@
export {setSort} from 'coral-embed-stream/src/actions/stream';
@@ -0,0 +1,16 @@
.label {
display: block;
cursor: pointer;
width: 100%;
padding: 6px 16px 6px 10px;
box-sizing: border-box;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
.input {
cursor: pointer;
margin-right: 6px;
}
@@ -0,0 +1,31 @@
import React from 'react';
import styles from './SortOption.css';
import PropTypes from 'prop-types';
export default class SortOption extends React.Component {
render() {
return (
<label className={styles.label}>
<input
type="radio"
onChange={this.props.setSort}
checked={this.props.active}
className={styles.input}
/>
{this.props.label}
</label>
);
}
}
SortOption.propTypes = {
// A simple callback to be called when clicking on this sort option.
setSort: PropTypes.func.isRequired,
// Whether or not this sort option is active.
active: PropTypes.bool.isRequired,
// Label to show next to the input control.
label: PropTypes.string.isRequired,
};
@@ -1,3 +1,5 @@
export {Slot} from 'coral-framework/components';
export {default as ClickOutside} from 'coral-framework/components/ClickOutside';
export {default as IfSlotIsEmpty} from 'coral-framework/components/IfSlotIsEmpty';
export {default as IfSlotIsNotEmpty} from 'coral-framework/components/IfSlotIsNotEmpty';
export {default as CommentAuthorName} from 'coral-framework/components/CommentAuthorName';
+12
View File
@@ -0,0 +1,12 @@
import withSortOption from '../hocs/withSortOption';
import SortOption from '../components/SortOption';
/**
* A factory creating a sort option component.
* @param {string|function} label label to display, can be a callback for lazy evaluation.
* @param {Object} sort sort parameters
* @param {string} sort.sortBy
* @param {string} sort.sortOrder
* @return {Object} Component
*/
export const createSortOption = (label, sort) => withSortOption({...sort, label})(SortOption);
+1
View File
@@ -1,5 +1,6 @@
export {default as withReaction} from './withReaction';
export {default as withTags} from './withTags';
export {default as withSortOption} from './withSortOption';
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';
@@ -0,0 +1,48 @@
import React from 'react';
import {connect} from 'plugin-api/beta/client/hocs';
import {bindActionCreators} from 'redux';
import {sortOrderSelector, sortBySelector} from 'plugin-api/beta/client/selectors/stream';
import {setSort} from 'plugin-api/beta/client/actions/stream';
import hoistStatics from 'recompose/hoistStatics';
const mapStateToProps = (state) => ({
sortOrder: sortOrderSelector(state),
sortBy: sortBySelector(state),
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators(
{
setSort
},
dispatch
);
/**
* A HOC providing props to implement a sort option.
* Provides the props `active`, `setSort`, `label`.
* @param {Object} sort
* @param {Object} sort.sortBy
* @param {string} sort.sortOrder
* @return {Object} HOC
*/
export default ({sortBy = 'created_at', sortOrder = 'DESC', label}) => hoistStatics((WrappedComponent) => {
class WithSortOption extends React.Component {
setSort = () => {
this.props.setSort({sortBy, sortOrder});
}
render() {
const active = this.props.sortOrder === sortOrder && this.props.sortBy === sortBy;
const resolvedLabel = typeof label === 'function' ? label() : label;
return (
<WrappedComponent
active={active}
setSort={this.setSort}
label={resolvedLabel}
/>
);
}
}
return connect(mapStateToProps, mapDispatchToProps)(WithSortOption);
});
@@ -0,0 +1,2 @@
export const sortOrderSelector = (state) => state.stream.sortOrder;
export const sortBySelector = (state) => state.stream.sortBy;
+3 -1
View File
@@ -1,8 +1,10 @@
export {
isTagged,
insertCommentsSorted,
prependNewNodes,
appendNewNodes,
getSlotFragmentSpreads,
forEachError,
capitalize,
getErrorMessages,
getDefinitionName,
} from 'coral-framework/utils';
@@ -1,12 +1,15 @@
const wrapResponse = require('../../../graph/helpers/response');
const {SEARCH_OTHER_USERS} = require('../../../perms/constants');
const errors = require('../../../errors');
const pluralize = require('pluralize');
function getReactionConfig(reaction) {
reaction = reaction.toLowerCase();
const reactionPlural = pluralize(reaction);
const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1);
const REACTION = reaction.toUpperCase();
const REACTION_PLURAL = reactionPlural.toUpperCase();
const typeDefs = `
enum ACTION_TYPE {
@@ -26,6 +29,13 @@ function getReactionConfig(reaction) {
item_id: ID!
}
enum SORT_COMMENTS_BY {
# Comments will be sorted by their count of ${reactionPlural}
# on the comment.
${REACTION_PLURAL}
}
input Delete${Reaction}ActionInput {
# The item's id for which we are deleting a ${reaction}.
@@ -107,6 +117,29 @@ function getReactionConfig(reaction) {
return {
typeDefs,
context: {
Sort: () => ({
Comments: {
[reactionPlural]: {
startCursor(ctx, nodes, {cursor}) {
// The cursor is the start! This is using numeric pagination.
return cursor != null ? cursor : 0;
},
endCursor(ctx, nodes, {cursor}) {
return nodes.length ? (cursor != null ? cursor : 0) + nodes.length : null;
},
sort(ctx, query, {cursor, sortOrder}) {
if (cursor) {
query = query.skip(cursor);
}
return query.sort({[`action_counts.${reaction}`]: sortOrder === 'DESC' ? -1 : 1, created_at: sortOrder === 'DESC' ? -1 : 1});
},
},
},
}),
},
resolvers: {
Subscription: {
[`${reaction}ActionCreated`]: ({action}) => {
+10 -2
View File
@@ -4,7 +4,11 @@
"talk-plugin-respect",
"talk-plugin-offtopic",
"talk-plugin-facebook-auth",
"talk-plugin-featured-comments"
"talk-plugin-featured-comments",
"talk-plugin-sort-newest",
"talk-plugin-sort-oldest",
"talk-plugin-sort-most-liked",
"talk-plugin-sort-most-replied"
],
"client": [
"talk-plugin-respect",
@@ -13,6 +17,10 @@
"talk-plugin-viewing-options",
"talk-plugin-comment-content",
"talk-plugin-permalink",
"talk-plugin-featured-comments"
"talk-plugin-featured-comments",
"talk-plugin-sort-newest",
"talk-plugin-sort-oldest",
"talk-plugin-sort-most-respected",
"talk-plugin-sort-most-replied"
]
}
@@ -9,7 +9,7 @@ export const FakeComment = ({username, created_at, body}) => (
<div className={styles.root}>
<span className={styles.authorName}>
{username}
</span>;
</span>
<PubDate created_at={created_at} />
<div className={styles.body}>
{body}
@@ -17,7 +17,7 @@ const enhance = compose(
withFragments({
asset: gql`
fragment TalkFeaturedComments_Tab_asset on Asset {
featuredCommentsCount: totalCommentCount(tags: ["FEATURED"], excludeIgnored: $excludeIgnored) @skip(if: $hasComment)
featuredCommentsCount: totalCommentCount(tags: ["FEATURED"]) @skip(if: $hasComment)
}`,
}),
excludeIf((props) => props.asset.featuredCommentsCount === 0),
@@ -6,7 +6,7 @@ import {withFragments, connect} from 'plugin-api/beta/client/hocs';
import Comment from '../containers/Comment';
import {addNotification} from 'plugin-api/beta/client/actions/notification';
import {viewComment} from 'coral-embed-stream/src/actions/stream';
import {insertCommentsSorted, getDefinitionName} from 'plugin-api/beta/client/utils';
import {appendNewNodes, getDefinitionName} from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
class TabPaneContainer extends React.Component {
@@ -18,7 +18,8 @@ class TabPaneContainer extends React.Component {
limit: 5,
cursor: this.props.asset.featuredComments.endCursor,
asset_id: this.props.asset.id,
sort: 'REVERSE_CHRONOLOGICAL',
sortOrder: this.props.data.variables.sortOrder,
sortBy: this.props.data.variables.sortBy,
excludeIgnored: this.props.data.variables.excludeIgnored,
},
updateQuery: (previous, {fetchMoreResult:{comments}}) => {
@@ -26,7 +27,7 @@ class TabPaneContainer extends React.Component {
asset: {
featuredComments: {
nodes: {
$apply: (nodes) => insertCommentsSorted(nodes, comments.nodes, 'REVERSE_CHRONOLOGICAL'),
$apply: (nodes) => appendNewNodes(nodes, comments.nodes),
},
hasNextPage: {$set: comments.hasNextPage},
endCursor: {$set: comments.endCursor},
@@ -47,8 +48,25 @@ class TabPaneContainer extends React.Component {
}
const LOAD_MORE_QUERY = gql`
query TalkFeaturedComments_LoadMoreComments($limit: Int = 5, $cursor: Date, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) {
comments(query: {limit: $limit, cursor: $cursor, tags: ["FEATURED"], asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) {
query TalkFeaturedComments_LoadMoreComments(
$limit: Int = 5
$cursor: Cursor
$asset_id: ID
$sortOrder: SORT_ORDER
$sortBy: SORT_COMMENTS_BY
$excludeIgnored: Boolean
) {
comments(
query: {
limit: $limit
cursor: $cursor
tags: ["FEATURED"]
asset_id: $asset_id,
sortOrder: $sortOrder
sortBy: $sortBy
excludeIgnored: $excludeIgnored
}
) {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
}
@@ -79,7 +97,14 @@ const enhance = compose(
asset: gql`
fragment TalkFeaturedComments_TabPane_asset on Asset {
id
featuredComments: comments(tags: ["FEATURED"], excludeIgnored: $excludeIgnored, deep: true) @skip(if: $hasComment) {
featuredComments: comments(
query: {
tags: ["FEATURED"]
sortOrder: $sortOrder
sortBy: $sortBy
}
deep: true
) @skip(if: $hasComment) {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
}
@@ -9,7 +9,7 @@ import ModTag from './containers/ModTag';
import ModSubscription from './containers/ModSubscription';
import {findCommentInEmbedQuery} from 'coral-embed-stream/src/graphql/utils';
import {insertCommentsSorted} from 'plugin-api/beta/client/utils';
import {prependNewNodes} from 'plugin-api/beta/client/utils';
export default {
reducer,
@@ -60,7 +60,7 @@ export default {
asset: {
featuredComments: {
nodes: {
$apply: (nodes) => insertCommentsSorted(nodes, comment, 'REVERSE_CHRONOLOGICAL')
$apply: (nodes) => prependNewNodes(nodes, [comment]),
}
},
featuredCommentsCount: {
@@ -0,0 +1,8 @@
.offTopic {
height: 100%;
}
.offTopicLabel {
padding: 10px 20px;
display: block;
}
@@ -1,5 +1,5 @@
import React from 'react';
import styles from './styles.css';
import styles from './OffTopicCheckbox.css';
import {t} from 'plugin-api/beta/client/services';
@@ -0,0 +1,20 @@
.label {
display: block;
cursor: pointer;
width: 100%;
padding: 6px 16px 6px 10px;
box-sizing: border-box;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
.input {
cursor: pointer;
margin-right: 6px;
}
:global(.talk-plugin-off-topic-comment) {
display: none;
}
@@ -1,5 +1,5 @@
import React from 'react';
import styles from './styles.css';
import styles from './OffTopicFilter.css';
export default class OffTopicFilter extends React.Component {
@@ -20,12 +20,15 @@ export default class OffTopicFilter extends React.Component {
render() {
return (
<div className={styles.viewingOption}>
<label>
<input type="checkbox" onChange={this.handleChange} checked={this.props.checked} />
Hide Off-Topic Comments
</label>
</div>
<label className={styles.label}>
<input
type="checkbox"
onChange={this.handleChange}
checked={this.props.checked}
className={styles.input}
/>
Hide Off-Topic Comments
</label>
);
}
}
@@ -0,0 +1,10 @@
.tag {
background: #D2D7D3;
font-size: 12px;
font-weight: bold;
color: #4A4A4A;
display: inline-block;
margin: 0px 5px;
padding: 5px 5px;
border-radius: 2px;
}
@@ -1,5 +1,5 @@
import React from 'react';
import styles from './styles.css';
import styles from './OffTopicTag.css';
import {t} from 'plugin-api/beta/client/services';
import {isTagged} from 'plugin-api/beta/client/utils';
@@ -1,27 +0,0 @@
.offTopic {
height: 100%;
}
.offTopicLabel {
padding: 10px 20px;
display: block;
}
.tag {
background: #D2D7D3;
font-size: 12px;
font-weight: bold;
color: #4A4A4A;
display: inline-block;
margin: 0px 5px;
padding: 5px 5px;
border-radius: 2px;
}
.viewingOption {
padding: 5px 0;
}
:global(.talk-plugin-off-topic-comment) {
display: none;
}
+2 -2
View File
@@ -6,7 +6,7 @@ import reducer from './reducer';
/**
* talk-plugin-offtopic depends on talk-plugin-viewing-options
* in other to display filter and use the streamViewingOptions slot
* in other to display filter.
*/
export default {
@@ -15,6 +15,6 @@ export default {
slots: {
commentInputDetailArea: [OffTopicCheckbox],
commentInfoBar: [OffTopicTag],
viewingOptions: [OffTopicFilter]
viewingOptionsFilter: [OffTopicFilter]
}
};
@@ -0,0 +1,14 @@
{
"presets": [
"es2015"
],
"plugins": [
"add-module-exports",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
]
}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,19 @@
import translations from './translations.yml';
import {createSortOption} from 'plugin-api/beta/client/factories';
import {t} from 'plugin-api/beta/client/services';
const SortOption = createSortOption(
() => t('talk-plugin-sort-most-liked.label'),
{sortBy: 'LIKES', sortOrder: 'DESC'},
);
/**
* This plugin depends on talk-plugin-viewing-options.
*/
export default {
translations,
slots: {
viewingOptionsSort: [SortOption]
}
};
@@ -0,0 +1,5 @@
en:
talk-plugin-sort-most-liked:
label: Most liked first
es:
talk-plugin-sort-most-liked:
@@ -0,0 +1,2 @@
module.exports = {
};
@@ -0,0 +1,14 @@
{
"presets": [
"es2015"
],
"plugins": [
"add-module-exports",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
]
}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,19 @@
import translations from './translations.yml';
import {createSortOption} from 'plugin-api/beta/client/factories';
import {t} from 'plugin-api/beta/client/services';
const SortOption = createSortOption(
() => t('talk-plugin-sort-most-loved.label'),
{sortBy: 'LOVES', sortOrder: 'DESC'},
);
/**
* This plugin depends on talk-plugin-viewing-options.
*/
export default {
translations,
slots: {
viewingOptionsSort: [SortOption]
}
};
@@ -0,0 +1,5 @@
en:
talk-plugin-sort-most-loved:
label: Most loved first
es:
talk-plugin-sort-most-loved:
@@ -0,0 +1,2 @@
module.exports = {
};
@@ -0,0 +1,14 @@
{
"presets": [
"es2015"
],
"plugins": [
"add-module-exports",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
]
}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,19 @@
import translations from './translations.yml';
import {createSortOption} from 'plugin-api/beta/client/factories';
import {t} from 'plugin-api/beta/client/services';
const SortOption = createSortOption(
() => t('talk-plugin-sort-most-replied.label'),
{sortBy: 'REPLIES', sortOrder: 'DESC'},
);
/**
* This plugin depends on talk-plugin-viewing-options.
*/
export default {
translations,
slots: {
viewingOptionsSort: [SortOption]
}
};
@@ -0,0 +1,5 @@
en:
talk-plugin-sort-most-replied:
label: Most replied first
es:
talk-plugin-sort-most-replied:
@@ -0,0 +1,2 @@
module.exports = {
};
@@ -0,0 +1,14 @@
{
"presets": [
"es2015"
],
"plugins": [
"add-module-exports",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
]
}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,19 @@
import translations from './translations.yml';
import {createSortOption} from 'plugin-api/beta/client/factories';
import {t} from 'plugin-api/beta/client/services';
const SortOption = createSortOption(
() => t('talk-plugin-sort-most-respected.label'),
{sortBy: 'RESPECTS', sortOrder: 'DESC'},
);
/**
* This plugin depends on talk-plugin-viewing-options.
*/
export default {
translations,
slots: {
viewingOptionsSort: [SortOption]
}
};
@@ -0,0 +1,5 @@
en:
talk-plugin-sort-most-respected:
label: Most respected first
es:
talk-plugin-sort-most-respected:
@@ -0,0 +1,2 @@
module.exports = {
};
@@ -0,0 +1,14 @@
{
"presets": [
"es2015"
],
"plugins": [
"add-module-exports",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
]
}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
@@ -0,0 +1,19 @@
import translations from './translations.yml';
import {createSortOption} from 'plugin-api/beta/client/factories';
import {t} from 'plugin-api/beta/client/services';
const SortOption = createSortOption(
() => t('talk-plugin-sort-newest.label'),
{sortBy: 'CREATED_AT', sortOrder: 'DESC'},
);
/**
* This plugin depends on talk-plugin-viewing-options.
*/
export default {
translations,
slots: {
viewingOptionsSort: [SortOption]
}
};
@@ -0,0 +1,5 @@
en:
talk-plugin-sort-newest:
label: Newest first
es:
talk-plugin-sort-newest:
+2
View File
@@ -0,0 +1,2 @@
module.exports = {
};
@@ -0,0 +1,14 @@
{
"presets": [
"es2015"
],
"plugins": [
"add-module-exports",
"transform-class-properties",
"transform-decorators-legacy",
"transform-object-assign",
"transform-object-rest-spread",
"transform-async-to-generator",
"transform-react-jsx"
]
}
@@ -0,0 +1,23 @@
{
"env": {
"browser": true,
"es6": true,
"mocha": true
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}

Some files were not shown because too many files have changed in this diff Show More