diff --git a/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js index 8d9f6b7e0..e823be9fe 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/ModerationHeader.js @@ -14,13 +14,13 @@ const ModerationHeader = props => ( {props.asset.title} - Select Stream + Select Stream :
All Streams - Select Stream + Select Stream
} diff --git a/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js b/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js index ffa1adfcd..90610a577 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/NotFoundAsset.js @@ -6,7 +6,7 @@ const NotFound = props => (

The provided asset id {props.assetId} does not exist. - Go to Streams + Go to Streams

); diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js index 7a72165ec..b67e9e1c4 100644 --- a/client/coral-embed-stream/src/Embed.js +++ b/client/coral-embed-stream/src/Embed.js @@ -1,4 +1,4 @@ -import React, {Component} from 'react'; +import React from 'react'; import {compose} from 'react-apollo'; import {connect} from 'react-redux'; import isEqual from 'lodash/isEqual'; @@ -36,15 +36,22 @@ import HighlightedComment from './Comment'; import LoadMore from './LoadMore'; import NewCount from './NewCount'; -class Embed extends Component { +class Embed extends React.Component { - state = {activeTab: 0, showSignInDialog: false, activeReplyBox: ''}; + constructor(props) { + super(props); + this.state = { + activeTab: 0, + showSignInDialog: false, + activeReplyBox: '' + }; + } changeTab = (tab) => { - const {isAdmin} = this.props.auth; // Everytime the comes from another tab, the Stream needs to be updated. - if (tab === 0 && isAdmin) { + if (tab === 0) { + this.props.viewAllComments(); this.props.data.refetch(); } @@ -82,10 +89,12 @@ class Embed extends Component { if(!isEqual(nextProps.data.asset, this.props.data.asset)) { loadAsset(nextProps.data.asset); - const {getCounts, updateCountCache} = this.props; + const {getCounts, updateCountCache, asset: {countCache}} = this.props; const {asset} = nextProps.data; - updateCountCache(asset.id, asset.commentCount); + if (!countCache) { + updateCountCache(asset.id, asset.commentCount); + } this.setState({ countPoll: setInterval(() => { @@ -130,6 +139,12 @@ class Embed extends Component { const banned = user && user.status === 'BANNED'; + const hasOlderComments = !!( + asset && + asset.lastComment && + asset.lastComment.id !== asset.comments[asset.comments.length - 1].id + ); + const expandForLogin = showSignInDialog ? { minHeight: document.body.scrollHeight + 200 } : {}; @@ -266,7 +281,7 @@ class Embed extends Component { topLevel={true} assetId={asset.id} comments={asset.comments} - moreComments={countCache[asset.id] > asset.comments.length} + moreComments={hasOlderComments} loadMore={this.props.loadMore} /> } diff --git a/client/coral-embed-stream/src/NewCount.js b/client/coral-embed-stream/src/NewCount.js index 2368c1ab8..8d7333aab 100644 --- a/client/coral-embed-stream/src/NewCount.js +++ b/client/coral-embed-stream/src/NewCount.js @@ -17,10 +17,10 @@ const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, upd const NewCount = (props) => { const newComments = props.commentCount - props.countCache; - return
+ return
{ props.countCache && newComments > 0 ? -
- {content} +
+ {content} +
; export default QuestionBox; diff --git a/client/coral-sign-in/components/FakeComment.js b/client/coral-sign-in/components/FakeComment.js index b5af6bf1c..f5926852c 100644 --- a/client/coral-sign-in/components/FakeComment.js +++ b/client/coral-sign-in/components/FakeComment.js @@ -28,10 +28,10 @@ class FakeComment extends React.Component { author={{'name': username}}/> -
+
- @@ -43,16 +43,16 @@ class FakeComment extends React.Component { banned={false} />
-
+
diff --git a/client/coral-sign-in/translations.json b/client/coral-sign-in/translations.json index 824888cca..7ae84b2ff 100644 --- a/client/coral-sign-in/translations.json +++ b/client/coral-sign-in/translations.json @@ -25,14 +25,14 @@ "emailInUse": "Email address already in use", "emailORusernameInUse": "Email address or Username already in use", "requiredField": "This field is required", - "passwordsDontMatch": "Passwords don\"t match.", + "passwordsDontMatch": "Passwords don't match.", "specialCharacters": "Usernames can contain letters, numbers and _ only", "checkTheForm": "Invalid Form. Please, check the fields" }, "createdisplay": { "writeyourusername": "Edit your username", "yourusername": "Your username appears on every comment you post.", - "ifyoudontchangeyourname": "If you don\"t change your username at this step, your Facebook display name will appear alongside of all your comments.", + "ifyoudontchangeyourname": "If you don't change your username at this step, your Facebook display name will appear alongside of all your comments.", "username": "Username", "continue": "Continue with the same Facebook username", "save": "Save", diff --git a/graph/hooks.js b/graph/hooks.js index 499eb60b1..c5b7541b3 100644 --- a/graph/hooks.js +++ b/graph/hooks.js @@ -3,6 +3,7 @@ const { GraphQLInterfaceType } = require('graphql'); const debug = require('debug')('talk:graph:schema'); +const Joi = require('joi'); /** * XXX taken from graphql-js: src/execution/execute.js, because that function @@ -82,6 +83,8 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa Object.keys(hooks).forEach((hook) => { switch (hook) { case 'pre': + Joi.assert(hooks.pre, Joi.func().maxArity(4)); + debug(`adding pre hook to resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`); if (typeof hooks.pre !== 'function') { @@ -91,6 +94,8 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa acc.pre.push(hooks.pre); break; case 'post': + Joi.assert(hooks.pre, Joi.func().maxArity(5)); + debug(`adding post hook to resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`); if (typeof hooks.post !== 'function') { @@ -129,6 +134,9 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa return; } + // Ensure it matches the format we expect. + Joi.assert(post, Joi.array().items(Joi.func().maxArity(3)), `invalid post hooks were found for ${typeName}.${fieldName}`); + // Cache the original resolverType function. let resolveType = field.resolveType; diff --git a/graph/resolvers/asset.js b/graph/resolvers/asset.js index 96bc8c1bc..c260d0070 100644 --- a/graph/resolvers/asset.js +++ b/graph/resolvers/asset.js @@ -1,4 +1,11 @@ const Asset = { + lastComment({id}, _, {loaders: {Comments}}) { + return Comments.getByQuery({ + asset_id: id, + limit: 1, + parent_id: null + }).then(data => data[0]); + }, recentComments({id}, _, {loaders: {Comments}}) { return Comments.genRecentComments.load(id); }, diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index aeae1537c..0aac36aaa 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -416,6 +416,9 @@ type Asset { # The URL that the asset is located on. url: String + # Returns last comment + lastComment: Comment + # Returns recent comments recentComments: [Comment] diff --git a/models/asset.js b/models/asset.js index f0046aef0..51ff42705 100644 --- a/models/asset.js +++ b/models/asset.js @@ -46,6 +46,12 @@ const AssetSchema = new Schema({ type: Schema.Types.Mixed, default: null }, + + // Additional metadata stored on the field. + metadata: { + default: {}, + type: Object + } }, { versionKey: false, timestamps: { diff --git a/models/comment.js b/models/comment.js index e6523e45f..0984a0832 100644 --- a/models/comment.js +++ b/models/comment.js @@ -75,7 +75,13 @@ const CommentSchema = new Schema({ default: 'NONE' }, tags: [TagSchema], - parent_id: String + parent_id: String, + + // Additional metadata stored on the field. + metadata: { + default: {}, + type: Object + } }, { timestamps: { createdAt: 'created_at', diff --git a/package.json b/package.json index f4e2eb725..41d8732de 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "talk", - "version": "1.4.0", - "description": "A commenting platform from The Coral Project. https://coralproject.net", + "version": "1.5.0", + "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", "scripts": { "postinstall": "./bin/cli plugins reconcile --skip-remote", @@ -17,7 +17,7 @@ "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": "yarn build" + "heroku-postbuild": "./bin/cli plugins reconcile && yarn build" }, "config": { "pre-git": { diff --git a/plugins.js b/plugins.js index 02cdb58e1..22293c975 100644 --- a/plugins.js +++ b/plugins.js @@ -47,8 +47,8 @@ const hookSchemas = { pre: Joi.func(), post: Joi.func() }))), - loaders: Joi.object().pattern(/\w/, Joi.object().pattern(/\w/, Joi.func())), - mutators: Joi.object().pattern(/\w/, Joi.object().pattern(/\w/, Joi.func())), + loaders: Joi.func().maxArity(1), + mutators: Joi.func().maxArity(1), resolvers: Joi.object().pattern(/\w/, Joi.object().pattern(/(?:__resolveType|\w+)/, Joi.func())), typeDefs: Joi.string() }; @@ -124,7 +124,12 @@ function itteratePlugins(plugins) { // Add each plugin folder to the allowed import path so that they can import our // internal dependancies. Object.keys(plugins).forEach((type) => itteratePlugins(plugins[type]).forEach((plugin) => { - amp.enableForDir(path.dirname(plugin.path)); + + // The plugin may be remote, and therefore not installed. We check here if the + // plugin path is available before trying to monkeypatch it's require path. + if (plugin.path) { + amp.enableForDir(path.dirname(plugin.path)); + } })); /** diff --git a/services/email/suspension.html.ejs b/services/email/suspension.html.ejs new file mode 100644 index 000000000..b36560ec5 --- /dev/null +++ b/services/email/suspension.html.ejs @@ -0,0 +1 @@ +<%= body %> diff --git a/services/metadata.js b/services/metadata.js new file mode 100644 index 000000000..2cabed8b8 --- /dev/null +++ b/services/metadata.js @@ -0,0 +1,80 @@ +/** + * The key must be composed of alpha characters with periods seperating them. + */ +const KEY_REGEX = /^(?:[A-Za-z][A-Za-z\.]*[A-Za-z])?(?:[A-Za-z]*)$/; + +/** + * Allows metadata properties to be set/unset from specific models. It is the + * expecatation of this API that the metadata field is either accessed later + * directly, or accessed as a result of another database load rather than + * this service providing an interface to do so. + * + * @class MetadataService + */ +class MetadataService { + + /** + * Parses a key by ensuring that if it is either a string, or an array with + * only characters defined in the `KEY_REGEX` + * + * @static + * @param {String|Array} key + * @returns {String} string form of the key + * + * @memberOf Metadata + */ + static parseKey(key) { + if (Array.isArray(key)) { + key = key.join('.'); + } + + if ((typeof key !== 'string') || !KEY_REGEX.test(key) || key.length === 0) { + throw new Error(`${key} is not valid, only a-zA-Z. allowed`); + } + + return ['metadata', key].join('.'); + } + + /** + * Sets an object on the metadata field of an object. + * + * @static + * @param {mongoose.Model} model the mongoose model for the object + * @param {String} id the value for the field `id` of the model + * @param {String|Array} key key for the metadata field + * @param {any} value javascript object to set the value of the metadata to + * @returns {Promise} resolves when the update is complete + * + * @memberOf Metadata + */ + static async set(model, id, key, value) { + key = MetadataService.parseKey(key); + + return model.update({id}, { + $set: { + [key]: value + } + }); + } + + /** + * Removes the value for the metadata field as the specific key. + * + * @static + * @param {mongoose.Model} model the mongoose model for the object + * @param {String} id the value for the field `id` of the model + * @param {String|Array} key key for the metadata field + * @returns + * + * @memberOf Metadata + */ + static async unset(model, id, key) { + key = MetadataService.parseKey(key); + + return model.update({id}, { + $unset: {[key]: ''} + }); + } +} + +module.exports = MetadataService;