diff --git a/.eslintignore b/.eslintignore
index c37217709..21af2df92 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -24,5 +24,6 @@ plugins/*
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-toxic-comments
+!plugins/talk-plugin-remember-sort
node_modules
diff --git a/.gitignore b/.gitignore
index 58683aec1..bba75a377 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,5 +41,6 @@ plugins/*
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-toxic-comments
+!plugins/talk-plugin-remember-sort
**/node_modules/*
diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js
index 5df2ce391..736d3efc9 100644
--- a/client/coral-admin/src/index.js
+++ b/client/coral-admin/src/index.js
@@ -12,23 +12,23 @@ import {toast} from 'react-toastify';
import {createNotificationService} from './services/notification';
import {hideShortcutsNote} from './actions/moderation';
-function hidrateStore({store, storage}) {
+smoothscroll.polyfill();
+
+function init({store, storage}) {
if (storage && storage.getItem('coral:shortcutsNote') === 'hide') {
store.dispatch(hideShortcutsNote());
}
}
-smoothscroll.polyfill();
+async function main() {
+ const notification = createNotificationService(toast);
+ const context = await createContext({reducers, graphqlExtension, pluginsConfig, notification, init});
+ render(
+
+
+
+ , document.querySelector('#root')
+ );
+}
-const notification = createNotificationService(toast);
-const context = createContext({reducers, graphqlExtension, pluginsConfig, notification});
-
-// hidrate Store with external data.
-hidrateStore(context);
-
-render(
-
-
-
- , document.querySelector('#root')
-);
+main();
diff --git a/client/coral-embed-stream/src/containers/Comment.js b/client/coral-embed-stream/src/containers/Comment.js
index 733708a97..bd3160643 100644
--- a/client/coral-embed-stream/src/containers/Comment.js
+++ b/client/coral-embed-stream/src/containers/Comment.js
@@ -92,12 +92,6 @@ const singleCommentFragment = gql`
const withCommentFragments = withFragments({
root: gql`
fragment CoralEmbedStream_Comment_root on RootQuery {
- me {
- ignoredUsers {
- id
- }
- }
- __typename
me {
ignoredUsers {
id
diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js
index cd374291d..132e7f5e7 100644
--- a/client/coral-embed-stream/src/containers/Embed.js
+++ b/client/coral-embed-stream/src/containers/Embed.js
@@ -215,6 +215,6 @@ const mapDispatchToProps = (dispatch) =>
export default compose(
connect(mapStateToProps, mapDispatchToProps),
- branch((props) => !props.auth.checkedInitialLogin && props.config, renderComponent(Spinner)),
+ branch((props) => !props.auth.checkedInitialLogin, renderComponent(Spinner)),
withEmbedQuery,
)(EmbedContainer);
diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js
index 5d72ea3e7..c8d3101ab 100644
--- a/client/coral-embed-stream/src/index.js
+++ b/client/coral-embed-stream/src/index.js
@@ -10,11 +10,6 @@ import reducers from './reducers';
import TalkProvider from 'coral-framework/components/TalkProvider';
import pluginsConfig from 'pluginsConfig';
-const context = createContext({reducers, graphqlExtension, pluginsConfig});
-
-// TODO: move init code into `bootstrap` service after auth has been refactored.
-const {store, pym} = context;
-
function inIframe() {
try {
return window.self !== window.top;
@@ -23,37 +18,45 @@ function inIframe() {
}
}
-function init(config = {}) {
- store.dispatch(addExternalConfig(config));
- store.dispatch(checkLogin());
-}
+// TODO: move init code into `bootstrap` service after auth has been refactored.
+function preInit({store, pym}) {
-// Don't run this in the popup.
-if (!window.opener) {
- if (inIframe()) {
+ // TODO: This is popup specific code and needs to be refactored.
+ if (!inIframe()) {
+ store.dispatch(addExternalConfig({}));
+ store.dispatch(checkLogin());
+ return;
+ }
+
+ pym.onMessage('login', (token) => {
+ if (token) {
+ store.dispatch(handleAuthToken(token));
+ }
+ store.dispatch(checkLogin());
+ });
+
+ pym.onMessage('logout', () => {
+ store.dispatch(logout());
+ });
+
+ return new Promise((resolve) => {
pym.sendMessage('getConfig');
pym.onMessage('config', (config) => {
- init(JSON.parse(config));
- });
-
- pym.onMessage('login', (token) => {
- if (token) {
- store.dispatch(handleAuthToken(token));
- }
+ store.dispatch(addExternalConfig(config));
store.dispatch(checkLogin());
+ resolve();
});
-
- pym.onMessage('logout', () => {
- store.dispatch(logout());
- });
- } else {
- init();
- }
+ });
}
-render(
-
-
-
- , document.querySelector('#talk-embed-stream-container')
-);
+async function main() {
+ const context = await createContext({reducers, graphqlExtension, pluginsConfig, preInit});
+ render(
+
+
+
+ , document.querySelector('#talk-embed-stream-container')
+ );
+}
+
+main();
diff --git a/client/coral-embed/src/Stream.js b/client/coral-embed/src/Stream.js
index b81964529..cb8bb5bca 100644
--- a/client/coral-embed/src/Stream.js
+++ b/client/coral-embed/src/Stream.js
@@ -3,6 +3,7 @@ import pym from 'pym.js';
import EventEmitter from 'eventemitter2';
import {buildUrl} from 'coral-framework/utils/url';
import Snackbar from './Snackbar';
+import {createStorage, connectStorageToPym} from 'coral-framework/services/storage';
const NOTIFICATION_OFFSET = 200;
@@ -134,6 +135,9 @@ export default class Stream {
// If the user clicks outside the embed, then tell the embed.
document.addEventListener('click', this.handleClick.bind(this), true);
+
+ // Listens to storage requests on pym and relay it to local storage.
+ connectStorageToPym(createStorage(), this.pym);
}
login(token) {
diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js
index 2b17ed465..34bee1797 100644
--- a/client/coral-embed/src/index.js
+++ b/client/coral-embed/src/index.js
@@ -83,7 +83,7 @@ Talk.render = (el, opts) => {
console.warn(
'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'
);
-
+
if (!window.location.origin) {
window.location.origin = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ''}`;
}
diff --git a/client/coral-framework/services/bootstrap.js b/client/coral-framework/services/bootstrap.js
index a3ae5b18e..492f6cc40 100644
--- a/client/coral-framework/services/bootstrap.js
+++ b/client/coral-framework/services/bootstrap.js
@@ -12,8 +12,10 @@ import {createPluginsService} from './plugins';
import {createNotificationService} from './notification';
import {createGraphQLRegistry} from './graphqlRegistry';
import globalFragments from 'coral-framework/graphql/fragments';
-import {createStorage} from 'coral-framework/services/storage';
+import {createStorage, createPymStorage} from 'coral-framework/services/storage';
import {createHistory} from 'coral-framework/services/history';
+import {createIntrospection} from 'coral-framework/services/introspection';
+import introspectionData from 'coral-framework/graphql/introspection.json';
/**
* getStaticConfiguration will return a singleton of the static configuration
@@ -60,17 +62,28 @@ const getAuthToken = (store, storage) => {
/**
* createContext setups and returns Talk dependencies that should be
* passed to `TalkProvider`.
- * @param {Object} [config] configuration
- * @param {Object} [config.reducers] extra reducers to add to redux
- * @param {Array} [config.pluginsConfig] plugin configuration as returned by importing pluginsConfig
- * @param {Object} [config.graphqlExtensions] additional extension to the graphql framework
- * @param {Object} [config.notification] replace default notification service
- * @return {Object} context
+ * @param {Object} [config] configuration
+ * @param {Object} [config.reducers] extra reducers to add to redux
+ * @param {Array} [config.pluginsConfig] plugin configuration as returned by importing pluginsConfig
+ * @param {Object} [config.graphqlExtensions] additional extension to the graphql framework
+ * @param {Object} [config.notification] replace default notification service
+ * @param {Function} [config.init] run initialization e.g. to hydrate redux store
+ * @param {Function} [config.preInit] same as init but run and resolve before init and plugins init
+ * @return {Object} context
*/
-export function createContext({reducers = {}, pluginsConfig = [], graphqlExtension = {}, notification} = {}) {
+export async function createContext({
+ reducers = {},
+ pluginsConfig = [],
+ graphqlExtension = {},
+ notification,
+ preInit,
+ init,
+} = {}) {
const eventEmitter = new EventEmitter({wildcard: true});
const storage = createStorage();
+ const pymStorage = createPymStorage(pym);
const history = createHistory(BASE_PATH);
+ const introspection = createIntrospection(introspectionData);
let store = null;
const token = () => {
@@ -104,6 +117,7 @@ export function createContext({reducers = {}, pluginsConfig = [], graphqlExtensi
uri: `${BASE_PATH}api/v1/graph/ql`,
liveUri,
token,
+ introspectionData,
});
const plugins = createPluginsService(pluginsConfig);
const graphqlRegistry = createGraphQLRegistry(plugins.getSlotFragments.bind(plugins));
@@ -123,6 +137,8 @@ export function createContext({reducers = {}, pluginsConfig = [], graphqlExtensi
notification,
storage,
history,
+ introspection,
+ pymStorage,
};
// Load framework fragments.
@@ -155,8 +171,14 @@ export function createContext({reducers = {}, pluginsConfig = [], graphqlExtensi
createReduxEmitter(eventEmitter),
]);
- return {
- ...context,
- store,
- };
+ context.store = store;
+
+ // Run pre initialization.
+ if (preInit) {
+ await preInit(context);
+ }
+
+ // Run initialization.
+ await Promise.all([init, plugins.executeInit(context)]);
+ return context;
}
diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js
index 5907488e3..52088ad79 100644
--- a/client/coral-framework/services/client.js
+++ b/client/coral-framework/services/client.js
@@ -1,7 +1,6 @@
import ApolloClient, {addTypename, IntrospectionFragmentMatcher, createNetworkInterface} from 'apollo-client';
import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws';
import MessageTypes from 'subscriptions-transport-ws/dist/message-types';
-import introspectionQueryResultData from '../graphql/introspection.json';
// Redux middleware to report any errors to the console.
export const apolloErrorReporter = () => (next) => (action) => {
@@ -21,10 +20,11 @@ function resolveToken(token) {
* @param {string|function} [options.token] auth token
* @param {string} [options.uri] uri of the graphql server
* @param {string} [options.liveUri] uri of the graphql subscription server
+ * @param {Object} [options.introspectionData] introspection query result data
* @return {Object} apollo client
*/
export function createClient(options = {}) {
- const {token, uri, liveUri} = options;
+ const {token, uri, liveUri, introspectionData} = options;
const wsClient = new SubscriptionClient(liveUri, {
reconnect: true,
lazy: true,
@@ -63,7 +63,7 @@ export function createClient(options = {}) {
const client = new ApolloClient({
connectToDevTools: true,
addTypename: true,
- fragmentMatcher: new IntrospectionFragmentMatcher({introspectionQueryResultData}),
+ fragmentMatcher: new IntrospectionFragmentMatcher({introspectionQueryResultData: introspectionData}),
queryTransformer: addTypename,
dataIdFromObject: (result) => {
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
diff --git a/client/coral-framework/services/introspection.js b/client/coral-framework/services/introspection.js
new file mode 100644
index 000000000..fd82d41d6
--- /dev/null
+++ b/client/coral-framework/services/introspection.js
@@ -0,0 +1,31 @@
+class Introspection {
+ _enums=null;
+
+ constructor(data) {
+ this._enums = data.__schema.types
+ .filter((type) => type.kind === 'ENUM')
+ .reduce((obj, enumType) => {
+ obj[enumType.name] = enumType.enumValues.map((value) => value.name);
+ return obj;
+ }, {});
+ }
+
+ /**
+ * isValidEnumValue returns true when given enum and value exists.
+ * @param {string} name
+ * @param {string} value
+ * @return {boolean}
+ */
+ isValidEnumValue(name, value) {
+ return this._enums[name] && this._enums[name].indexOf(value) >= 0;
+ }
+}
+
+/**
+ * createIntrospection returns a introspection service
+ * @param {Object} data introspection query data
+ * @return {Object} introspection service
+ */
+export function createIntrospection(data) {
+ return new Introspection(data);
+}
diff --git a/client/coral-framework/services/plugins.js b/client/coral-framework/services/plugins.js
index ac752fa13..809286eb0 100644
--- a/client/coral-framework/services/plugins.js
+++ b/client/coral-framework/services/plugins.js
@@ -182,6 +182,14 @@ class PluginsService {
.map((o) => ({[camelize(o.name)] : o.module.reducer}))
);
}
+
+ async executeInit(context) {
+ const results = this.plugins
+ .map((o) => o.module.init)
+ .filter((fn) => fn)
+ .map((fn) => fn(context));
+ await Promise.all(results);
+ }
}
/**
diff --git a/client/coral-framework/services/storage.js b/client/coral-framework/services/storage.js
index 9f06595fa..11aa5df98 100644
--- a/client/coral-framework/services/storage.js
+++ b/client/coral-framework/services/storage.js
@@ -1,3 +1,4 @@
+import uuid from 'uuid/v4';
function getStorage(type) {
let storage = window[type], x = '__storage_test__';
@@ -41,3 +42,91 @@ function getStorage(type) {
export function createStorage() {
return getStorage('localStorage');
}
+
+/**
+ * Creates a storage that relay requests over to pym.
+ * This is the counterpart of `connectStorageToPym`.
+ * @param {string} pym pym
+ * @return {Object} storage
+ */
+export function createPymStorage(pym) {
+
+ // A Map of requestID => {resolve, reject}
+ const requests = {};
+
+ // Requests method with parameters over pym.
+ const call = (method, parameters) => {
+ const id = uuid();
+ return new Promise((resolve, reject) => {
+ requests[id] = {resolve, reject};
+ pym.sendMessage('pymStorage.request', JSON.stringify({id, method, parameters}));
+ });
+ };
+
+ // Receive successful responses.
+ pym.onMessage('pymStorage.response', (msg) => {
+ const {id, result} = JSON.parse(msg);
+ requests[id].resolve(result);
+ delete requests[id];
+ });
+
+ // Receive error responses.
+ pym.onMessage('pymStorage.error', (msg) => {
+ const {id, error} = JSON.parse(msg);
+ requests[id].reject(error);
+ delete requests[id];
+ });
+
+ return {
+ setItem: (key, value) => call('setItem', {key, value}),
+ getItem: (key, value) => call('getItem', {key, value}),
+ removeItem: (key) => call('removeItem', {key}),
+ };
+}
+
+/**
+ * Listens to `pym` and relay storage requests to `storage`.
+ * This is the counterpart of `createPymStorage`.
+ * @param {Object} storage storage to perform requests on
+ * @param {Object} pym pym to listen to storage requests
+ * @param {string} prefix namespace requests by prepending a prefix to the keys
+ */
+export function connectStorageToPym(storage, pym, prefix = 'talkPymStorage:') {
+ pym.onMessage('pymStorage.request', (msg) => {
+ const {id, method, parameters} = JSON.parse(msg);
+ const {key, value} = parameters;
+ const prefixedKey = `${prefix}${key}`;
+
+ // Variable for the method return value.
+ let result;
+
+ const sendError = (error) => {
+ console.error(error);
+ pym.sendMessage('pymStorage.error', JSON.stringify({id, error}));
+ };
+
+ try {
+ switch(method) {
+ case 'setItem':
+ result = storage.setItem(prefixedKey, value);
+ break;
+ case 'getItem':
+ result = storage.getItem(prefixedKey);
+ break;
+ case 'removeItem':
+ result = storage.removeItem(prefixedKey);
+ break;
+ default:
+ sendError(`Unknown method ${method}`);
+ return;
+ }
+ }
+ catch(err) {
+ sendError(err.toString());
+ return;
+ }
+
+ pym.sendMessage('pymStorage.response', JSON.stringify({id, result}));
+ });
+}
+
diff --git a/docs/_docs/01-02-install-docker.md b/docs/_docs/01-02-install-docker.md
index 9d177049e..7fd6f6ddf 100644
--- a/docs/_docs/01-02-install-docker.md
+++ b/docs/_docs/01-02-install-docker.md
@@ -53,6 +53,10 @@ services:
environment:
- TALK_MONGO_URL=mongodb://mongo/talk
- TALK_REDIS_URL=redis://redis
+ - TALK_ROOT_URL=http://localhost:5000
+ - TALK_JWT_SECRET=password
+ - TALK_FACEBOOK_APP_ID=12345
+ - TALK_FACEBOOK_APP_SECRET=123abc
mongo:
image: mongo:3.2
restart: always
@@ -70,10 +74,10 @@ volumes:
external: false
```
-At this stage, you should refer to the `README.md` for configuration variables
-that are specific to your installation. Some pre-defined fields have been filled
-in the above example which are consistent with Docker Compose naming conventions
-for [Docker Links](https://docs.docker.com/compose/networking/#links).
+At this stage, you should refer to the [configuration](https://coralproject.github.io/talk/docs/running/configuration/)
+for configuration variables that are specific to your installation. Some
+pre-defined fields have been filled in the above example which are consistent
+with Docker Compose naming conventions for [Docker Links](https://docs.docker.com/compose/networking/#links).
### Scaling
diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js
index 9142687f6..c6e335018 100644
--- a/graph/mutators/comment.js
+++ b/graph/mutators/comment.js
@@ -194,16 +194,32 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu
* @param {String} [asset_id] id of asset comment is posted on
* @return {Object} resolves to the wordlist results
*/
-const filterNewComment = (context, {body, asset_id}) => {
+const filterNewComment = async (context, {body, asset_id}) => {
+
+ // Load the settings.
+ const [
+ settings,
+ asset,
+ ] = await Promise.all([
+ context.loaders.Settings.load(),
+ context.loaders.Assets.getByID.load(asset_id),
+ ]);
// Create a new instance of the Wordlist.
const wl = new Wordlist();
+ // Load the wordlist.
+ wl.upsert(settings.wordlist);
+
// Load the wordlist and filter the comment content.
- return Promise.all([
- wl.load().then(() => wl.scan('body', body)),
- asset_id && AssetsService.rectifySettings(AssetsService.findById(asset_id))
- ]);
+ return [
+
+ // Scan the word.
+ wl.scan('body', body),
+
+ // Return the asset's settings.
+ await AssetsService.rectifySettings(asset, settings)
+ ];
};
/**
@@ -247,7 +263,7 @@ const resolveNewCommentStatus = async (context, {asset_id, body, status}, wordli
// Return `premod` if pre-moderation is enabled and an empty "new" status
// in the event that it is not in pre-moderation mode.
- let {moderation, charCountEnable, charCount} = await AssetsService.rectifySettings(asset);
+ let {moderation, charCountEnable, charCount} = await AssetsService.rectifySettings(asset, settings);
// Reject if the comment is too long
if (charCountEnable && body.length > charCount) {
@@ -365,7 +381,7 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
const status = await resolveNewCommentStatus(context, {asset_id, body}, wordlist, settings);
// Execute the edit.
- const comment = await CommentsService.edit(id, context.user.id, {body, status});
+ const comment = await CommentsService.edit({id, author_id: context.user.id, body, status});
// Publish the edited comment via the subscription.
context.pubsub.publish('commentEdited', comment);
diff --git a/plugins/talk-plugin-remember-sort/client/.babelrc b/plugins/talk-plugin-remember-sort/client/.babelrc
new file mode 100644
index 000000000..60be246eb
--- /dev/null
+++ b/plugins/talk-plugin-remember-sort/client/.babelrc
@@ -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"
+ ]
+}
\ No newline at end of file
diff --git a/plugins/talk-plugin-remember-sort/client/.eslintrc.json b/plugins/talk-plugin-remember-sort/client/.eslintrc.json
new file mode 100644
index 000000000..9fe56bd14
--- /dev/null
+++ b/plugins/talk-plugin-remember-sort/client/.eslintrc.json
@@ -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"] }]
+ }
+}
diff --git a/plugins/talk-plugin-remember-sort/client/index.js b/plugins/talk-plugin-remember-sort/client/index.js
new file mode 100644
index 000000000..061f71809
--- /dev/null
+++ b/plugins/talk-plugin-remember-sort/client/index.js
@@ -0,0 +1,40 @@
+import {setSort} from 'plugin-api/beta/client/actions/stream';
+import {sortOrderSelector, sortBySelector} from 'plugin-api/beta/client/selectors/stream';
+
+const STORAGE_PATH = 'talkPluginRememberSort';
+
+export default {
+ init: async ({store, pymStorage, introspection}) => {
+
+ // TODO: workaround as this plugin is included in any target and
+ // embeds (e.g. admin), but should only be included inside the stream.
+
+ // Detect if we are currently running inside the stream.
+ if (!store.getState().stream) {
+ return;
+ }
+
+ // We use pymStorage instead to persist the data directly on the parent page,
+ // in order to mitigate strict cross domain security settings.
+
+ let sort = JSON.parse(await pymStorage.getItem(STORAGE_PATH));
+ if (
+ sort &&
+ introspection.isValidEnumValue('SORT_ORDER', sort.sortOrder) &&
+ introspection.isValidEnumValue('SORT_COMMENTS_BY', sort.sortBy)
+ ) {
+ store.dispatch(setSort(sort));
+ }
+ store.subscribe(() => {
+ const state = store.getState();
+ const sortOrder = sortOrderSelector(state);
+ const sortBy = sortBySelector(state);
+
+ // Save sorting choice to storage if it has changed.
+ if (!sort || sort.sortOrder !== sortOrder || sort.sortBy !== sortBy) {
+ sort = {sortOrder, sortBy};
+ pymStorage.setItem(STORAGE_PATH, JSON.stringify(sort));
+ }
+ });
+ }
+};
diff --git a/plugins/talk-plugin-remember-sort/index.js b/plugins/talk-plugin-remember-sort/index.js
new file mode 100644
index 000000000..f053ebf79
--- /dev/null
+++ b/plugins/talk-plugin-remember-sort/index.js
@@ -0,0 +1 @@
+module.exports = {};
diff --git a/services/assets.js b/services/assets.js
index 34c8ff465..8f93de009 100644
--- a/services/assets.js
+++ b/services/assets.js
@@ -3,6 +3,7 @@ const AssetModel = require('../models/asset');
const SettingsService = require('./settings');
const domainlist = require('./domainlist');
const errors = require('../errors');
+const merge = require('lodash/merge');
module.exports = class AssetsService {
@@ -28,19 +29,23 @@ module.exports = class AssetsService {
* @param {Promise} assetQuery an asset query that returns a single asset.
* @return {Promise}
*/
- static rectifySettings(assetQuery) {
- return Promise.all([
- SettingsService.retrieve(),
- assetQuery
- ]).then(([settings, asset]) => {
+ static async rectifySettings(assetQuery, settings = null) {
+ const [
+ globalSettings,
+ asset,
+ ] = await Promise.all([
+ settings !== null ? settings : SettingsService.retrieve(),
+ assetQuery,
+ ]);
- // If the asset exists and has settings then return the merged object.
- if (asset && asset.settings) {
- settings.merge(asset.settings);
- }
+ // If the asset exists and has settings then return the merged object.
+ if (asset && asset.settings) {
+ settings = merge({}, globalSettings, asset.settings);
+ } else {
+ settings = globalSettings;
+ }
- return settings;
- });
+ return settings;
}
/**
diff --git a/services/comments.js b/services/comments.js
index db0714233..82914bcd0 100644
--- a/services/comments.js
+++ b/services/comments.js
@@ -5,6 +5,7 @@ const ActionsService = require('./actions');
const SettingsService = require('./settings');
const sc = require('snake-case');
+const cloneDeep = require('lodash/cloneDeep');
const errors = require('../errors');
const events = require('./events');
const {
@@ -52,13 +53,34 @@ module.exports = class CommentsService {
}
/**
- * Edit a Comment
- * @param {String} id comment.id you want to edit (or its ID)
- * @param {String} author_id user.id of the user trying to edit the comment (will err if not comment author)
+ * lastUnmoderatedStatus will retrieve the last status before this one.
+ *
+ * @param {Object} comment the comment to get the last status of
+ */
+ static lastUnmoderatedStatus(comment) {
+ const UNMODERATED_STATUSES = [
+ 'NONE',
+ 'PREMOD',
+ ];
+
+ for (let i = comment.status_history.length - 1; i >= 0; i--) {
+ const {type} = comment.status_history[i];
+
+ if (UNMODERATED_STATUSES.includes(type)) {
+ return type;
+ }
+ }
+ }
+
+ /**
+ * Edit a Comment.
+ *
+ * @param {String} id comment.id you want to edit (or its ID)
+ * @param {String} author_id user.id of the user trying to edit the comment (will err if not comment author)
* @param {String} body the new Comment body
* @param {String} status the new Comment status
*/
- static async edit(id, author_id, {body, status, ignoreEditWindow = false}) {
+ static async edit({id, author_id, body, status}) {
const query = {
id,
author_id,
@@ -69,14 +91,11 @@ module.exports = class CommentsService {
// Establish the edit window (if it exists) and add the condition to the
// original query.
- let lastEditableCommentCreatedAt;
- if (!ignoreEditWindow) {
- const {editCommentWindowLength: editWindowMs} = await SettingsService.retrieve();
- lastEditableCommentCreatedAt = new Date((new Date()).getTime() - editWindowMs);
- query.created_at = {
- $gt: lastEditableCommentCreatedAt,
- };
- }
+ const {editCommentWindowLength: editWindowMs} = await SettingsService.retrieve();
+ const lastEditableCommentCreatedAt = new Date(Date.now() - editWindowMs);
+ query.created_at = {
+ $gt: lastEditableCommentCreatedAt,
+ };
const originalComment = await CommentModel.findOneAndUpdate(query, {
$set: {
@@ -117,7 +136,7 @@ module.exports = class CommentsService {
}
// Check to see if the edit window expired.
- if (!ignoreEditWindow && comment.created_at <= lastEditableCommentCreatedAt) {
+ if (comment.created_at <= lastEditableCommentCreatedAt) {
debug('rejecting comment edit because outside edit time window');
throw errors.ErrEditWindowHasEnded;
}
@@ -126,7 +145,7 @@ module.exports = class CommentsService {
}
// Mutate the comment like Mongo would have.
- const editedComment = originalComment;
+ const editedComment = cloneDeep(originalComment);
editedComment.status = status;
editedComment.body = body;
editedComment.body_history.push({
@@ -138,6 +157,43 @@ module.exports = class CommentsService {
created_at: new Date(),
});
+ // We should adjust the comment's status such that if it was approved
+ // previously, we should mark the comment as 'NONE' or 'PREMOD', which ever
+ // was most recent if the new comment is destined to be `NONE` or `PREMOD`.
+ if (originalComment.status === 'ACCEPTED' && status === 'NONE') {
+
+ const lastUnmoderatedStatus = CommentsService.lastUnmoderatedStatus(originalComment);
+
+ // If the last moderated status was found and the current comment doesn't
+ // match this already.
+ if (lastUnmoderatedStatus && status !== lastUnmoderatedStatus) {
+
+ // Update the comment model (if at this point, the status is still
+ // accepted) with the previously unmoderated status
+ await CommentModel.update({
+ id,
+ status,
+ }, {
+ $set: {
+ status: lastUnmoderatedStatus,
+ },
+ $push: {
+ status_history: {
+ type: lastUnmoderatedStatus,
+ created_at: new Date(),
+ }
+ },
+ });
+
+ // Update the returned comment.
+ editedComment.status = lastUnmoderatedStatus;
+ editedComment.status_history.push({
+ type: lastUnmoderatedStatus,
+ created_at: new Date(),
+ });
+ }
+ }
+
await events.emitAsync(COMMENTS_EDIT, originalComment, editedComment);
return editedComment;
@@ -215,23 +271,6 @@ module.exports = class CommentsService {
return CommentModel.find({status});
}
- /**
- * Find comments that need to be moderated (aka moderation queue).
- * @param {String} asset_id
- * @return {Promise}
- */
- static moderationQueue(status = 'NONE', asset_id = null) {
-
- // Fetch the comments with statuses.
- let comments = CommentModel.find({status});
-
- if (asset_id) {
- comments = comments.where({asset_id});
- }
-
- return comments;
- }
-
/**
* Pushes a new status in for the user.
* @param {String} id identifier of the comment (uuid)
@@ -356,7 +395,7 @@ events.on(COMMENTS_NEW, async (comment) => {
if (
!comment || // Check that the comment is defined.
(!comment.parent_id || comment.parent_id.length === 0) || // Check that the comment has a parent (is a reply).
- !(comment.status === 'NONE' || comment.status === 'APPROVED') // Check that the comment is visible.
+ !(comment.status === 'NONE' || comment.status === 'ACCEPTED') // Check that the comment is visible.
) {
return;
}
diff --git a/test/server/services/comments.js b/test/server/services/comments.js
index e33089325..77711d321 100644
--- a/test/server/services/comments.js
+++ b/test/server/services/comments.js
@@ -10,7 +10,6 @@ const CommentsService = require('../../../services/comments');
const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
const chai = require('chai');
-chai.use(require('chai-as-promised'));
chai.use(require('sinon-chai'));
const expect = chai.expect;
@@ -96,102 +95,118 @@ describe('services.CommentsService', () => {
user_id: '456'
}];
- beforeEach(() => {
- return SettingsService.init(settings).then(() => {
- return Promise.all([
- CommentModel.create(comments),
- UsersService.createLocalUsers(users),
- ActionModel.create(actions)
- ]);
- });
+ beforeEach(async () => {
+ await SettingsService.init(settings);
+
+ await Promise.all([
+ CommentModel.create(comments),
+ UsersService.createLocalUsers(users),
+ ActionModel.create(actions)
+ ]);
});
describe('#publicCreate()', () => {
- it('creates a new comment', () => {
- return CommentsService
- .publicCreate({
- body: 'This is a comment!',
- status: 'ACCEPTED'
- }).then((c) => {
- expect(c).to.not.be.null;
- expect(c.id).to.not.be.null;
- expect(c.id).to.be.uuid;
- expect(c.status).to.be.equal('ACCEPTED');
- });
+ it('creates a new comment', async () => {
+ const c = await CommentsService.publicCreate({
+ body: 'This is a comment!',
+ status: 'ACCEPTED'
+ });
+
+ expect(c).to.not.be.null;
+ expect(c.id).to.not.be.null;
+ expect(c.id).to.be.uuid;
+ expect(c.status).to.be.equal('ACCEPTED');
});
- it('creates many new comments', () => {
- return CommentsService
- .publicCreate([{
- body: 'This is a comment!',
- status: 'ACCEPTED'
- }, {
- body: 'This is another comment!'
- }, {
- body: 'This is a rejected comment!',
- status: 'REJECTED'
- }]).then(([c1, c2, c3]) => {
- expect(c1).to.not.be.null;
- expect(c1.id).to.be.uuid;
- expect(c1.status).to.be.equal('ACCEPTED');
+ it('creates many new comments', async () => {
+ const [
+ c1,
+ c2,
+ c3,
+ ] = await CommentsService.publicCreate([{
+ body: 'This is a comment!',
+ status: 'ACCEPTED'
+ }, {
+ body: 'This is another comment!'
+ }, {
+ body: 'This is a rejected comment!',
+ status: 'REJECTED'
+ }]);
- expect(c2).to.not.be.null;
- expect(c2.id).to.be.uuid;
- expect(c2.status).to.be.equal('NONE');
+ expect(c1).to.not.be.null;
+ expect(c1.status).to.be.equal('ACCEPTED');
- expect(c3).to.not.be.null;
- expect(c3.id).to.be.uuid;
- expect(c3.status).to.be.equal('REJECTED');
- });
+ expect(c2).to.not.be.null;
+ expect(c2.status).to.be.equal('NONE');
+
+ expect(c3).to.not.be.null;
+ expect(c3.status).to.be.equal('REJECTED');
});
+ });
+ describe('#edit', () => {
+ it('changes the comment status back to premod if it was accepted', async () => {
+ const originalComment = await CommentsService.publicCreate({
+ body: 'this is a body!',
+ status: 'PREMOD',
+ author_id: '123',
+ });
+
+ expect(originalComment.status_history).to.have.length(1);
+
+ await CommentsService.pushStatus(originalComment.id, 'ACCEPTED');
+
+ let retrivedComment = await CommentsService.findById(originalComment.id);
+
+ expect(retrivedComment).to.have.property('status', 'ACCEPTED');
+ expect(retrivedComment.status_history).to.have.length(2);
+ expect(retrivedComment.status_history[1]).to.have.property('type', 'ACCEPTED');
+
+ const editedComment = await CommentsService.edit({
+ id: originalComment.id,
+ author_id: '123',
+ body: 'This is a body!',
+ status: 'NONE',
+ });
+
+ expect(editedComment).to.have.property('status', 'PREMOD');
+ expect(editedComment.status_history).to.have.length(4);
+ expect(editedComment.status_history[3]).to.have.property('type', 'PREMOD');
+
+ retrivedComment = await CommentsService.findById(originalComment.id);
+
+ expect(retrivedComment).to.have.property('status', 'PREMOD');
+ expect(retrivedComment.status_history).to.have.length(4);
+ expect(retrivedComment.status_history[3]).to.have.property('type', 'PREMOD');
+ });
});
describe('#findById()', () => {
- it('should find a comment by id', () => {
- return CommentsService
- .findById('1')
- .then((result) => {
- expect(result).to.not.be.null;
- expect(result).to.have.property('body', 'comment 10');
- });
+ it('should find a comment by id', async () => {
+ const comment = await CommentsService.findById('1');
+ expect(comment).to.not.be.null;
+ expect(comment).to.have.property('body', 'comment 10');
});
});
describe('#findByAssetId()', () => {
- it('should find an array of all comments by asset id', () => {
- return CommentsService
- .findByAssetId('123')
- .then((result) => {
- expect(result).to.have.length(3);
- result.sort((a, b) => {
- if (a.body < b.body) {return -1;}
- else {return 1;}
- });
- expect(result[0]).to.have.property('body', 'comment 10');
- expect(result[1]).to.have.property('body', 'comment 20');
- expect(result[2]).to.have.property('body', 'comment 40');
- });
+ it('should find an array of all comments by asset id', async () => {
+ const comments = await CommentsService.findByAssetId('123');
+ expect(comments).to.have.length(3);
+ comments.sort((a, b) => {
+ if (a.body < b.body) {return -1;}
+ else {return 1;}
+ });
+ expect(comments[0]).to.have.property('body', 'comment 10');
+ expect(comments[1]).to.have.property('body', 'comment 20');
+ expect(comments[2]).to.have.property('body', 'comment 40');
});
});
- describe('#moderationQueue()', () => {
-
- it('should find an array of new comments to moderate when pre-moderation', () => {
- return CommentsService
- .moderationQueue('PREMOD')
- .then((result) => {
- expect(result).to.not.be.null;
- expect(result).to.have.lengthOf(2);
- });
- });
-
- });
-
describe('#changeStatus', () => {
it('should change the status of a comment from no status', async () => {
@@ -220,20 +235,18 @@ describe('services.CommentsService', () => {
expect(c3.status_history[0]).to.have.property('assigned_by', '123');
});
- it('should change the status of a comment from accepted', () => {
- return CommentsService.pushStatus(comments[1].id, 'REJECTED', '123')
- .then(() => CommentsService.findById(comments[1].id))
- .then((c) => {
- expect(c).to.have.property('status_history');
- expect(c).to.have.property('status');
- expect(c.status).to.equal('REJECTED');
- expect(c.status_history).to.have.length(2);
- expect(c.status_history[0]).to.have.property('type', 'ACCEPTED');
- expect(c.status_history[0]).to.have.property('assigned_by', null);
+ it('should change the status of a comment from accepted', async () => {
+ await CommentsService.pushStatus(comments[1].id, 'REJECTED', '123');
+ const c = await CommentsService.findById(comments[1].id);
+ expect(c).to.have.property('status_history');
+ expect(c).to.have.property('status');
+ expect(c.status).to.equal('REJECTED');
+ expect(c.status_history).to.have.length(2);
+ expect(c.status_history[0]).to.have.property('type', 'ACCEPTED');
+ expect(c.status_history[0]).to.have.property('assigned_by', null);
- expect(c.status_history[1]).to.have.property('type', 'REJECTED');
- expect(c.status_history[1]).to.have.property('assigned_by', '123');
- });
+ expect(c.status_history[1]).to.have.property('type', 'REJECTED');
+ expect(c.status_history[1]).to.have.property('assigned_by', '123');
});
});
});