diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index e212af99b..eaaee3034 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -311,11 +311,11 @@ export const checkLogin = () => (dispatch) => { throw new Error('Not logged in'); } - dispatch(checkLoginSuccess(result.user)); - // Reset the websocket. resetWebsocket(); + dispatch(checkLoginSuccess(result.user)); + // Display create username dialog if necessary. if (result.user.canEditName && result.user.status !== 'BANNED') { dispatch(showCreateUsernameDialog()); diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index ef4c2ac74..01b3daff4 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -1,5 +1,4 @@ export {default as withFragments} from './withFragments'; export {default as withMutation} from './withMutation'; export {default as withQuery} from './withQuery'; -export {default as withReaction} from './withReaction'; diff --git a/client/coral-framework/hocs/withReaction.js b/client/coral-framework/hocs/withReaction.js deleted file mode 100644 index ca373b6bd..000000000 --- a/client/coral-framework/hocs/withReaction.js +++ /dev/null @@ -1,240 +0,0 @@ -import React from 'react'; -import get from 'lodash/get'; -import uuid from 'uuid/v4'; -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import {getDisplayName} from '../helpers/hoc'; -import {compose, gql, graphql} from 'react-apollo'; -import withFragments from 'coral-framework/hocs/withFragments'; -import {showSignInDialog} from 'coral-framework/actions/auth'; -import {capitalize} from 'coral-framework/helpers/strings'; -import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils'; - -export default (reaction) => (WrappedComponent) => { - if (typeof reaction !== 'string') { - console.error('Reaction must be a valid string'); - return null; - } - - reaction = reaction.toLowerCase(); - - class WithReactions extends React.Component { - render() { - const {comment} = this.props; - - const reactionSummary = getMyActionSummary( - `${capitalize(reaction)}ActionSummary`, - comment - ); - - const count = getTotalActionCount( - `${capitalize(reaction)}ActionSummary`, - comment - ); - - const alreadyReacted = () => !!reactionSummary; - - const withReactionProps = {reactionSummary, count, alreadyReacted}; - - return ; - } - } - - const isReaction = (a) => - a.__typename === `${capitalize(reaction)}ActionSummary`; - - const COMMENT_FRAGMENT = gql` - fragment ${capitalize(reaction)}Button_updateFragment on Comment { - action_summaries { - ... on ${capitalize(reaction)}ActionSummary { - count - current_user { - id - } - } - } - } - `; - - const withDeleteReaction = graphql( - gql` - mutation deleteReaction($id: ID!) { - deleteAction(id:$id) { - errors { - translation_key - } - } - } - `, - { - props: ({mutate, ownProps}) => ({ - deleteReaction: () => { - - const reactionSummary = getMyActionSummary( - `${capitalize(reaction)}ActionSummary`, - ownProps.comment - ); - - const reactionData = { - id: reactionSummary.current_user.id, - commentId: ownProps.comment.id - }; - - return mutate({ - variables: {id: reactionData.id}, - optimisticResponse: { - deleteAction: { - __typename: 'DeleteActionResponse', - errors: null - } - }, - update: (proxy) => { - const fragmentId = `Comment_${reactionData.commentId}`; - - // Read the data from our cache for this query. - const data = proxy.readFragment({ - fragment: COMMENT_FRAGMENT, - id: fragmentId - }); - - // Check whether we liked this comment. - const idx = data.action_summaries.findIndex(isReaction); - if ( - idx < 0 || - get(data.action_summaries[idx], 'current_user.id') !== reactionData.id - ) { - return; - } - - data.action_summaries[idx] = { - ...data.action_summaries[idx], - count: data.action_summaries[idx].count - 1, - current_user: null - }; - - // Write our data back to the cache. - proxy.writeFragment({ - fragment: COMMENT_FRAGMENT, - id: fragmentId, - data - }); - } - }); - } - }) - } - ); - - const withPostReaction = graphql( - gql` - mutation create${capitalize(reaction)}($${reaction}: Create${capitalize(reaction)}Input!) { - create${capitalize(reaction)}(${reaction}: $${reaction}) { - ${reaction} { - id - } - errors { - translation_key - } - } - } - `, - { - props: ({mutate, ownProps}) => ({ - postReaction: () => { - - const reactionData = { - item_id: ownProps.comment.id, - item_type: 'COMMENTS' - }; - - return mutate({ - variables: {[reaction]: reactionData}, - optimisticResponse: { - [`create${capitalize(reaction)}`]: { - __typename: `Create${capitalize(reaction)}Response`, - errors: null, - [reaction]: { - __typename: `${capitalize(reaction)}Action`, - id: uuid() - } - } - }, - update: (proxy, mutationResult) => { - const fragmentId = `Comment_${reactionData.item_id}`; - - // Read the data from our cache for this query. - const data = proxy.readFragment({ - fragment: COMMENT_FRAGMENT, - id: fragmentId - }); - - // Add our comment from the mutation to the end. - let idx = data.action_summaries.findIndex(isReaction); - - // Check whether we already reactioned this comment. - if (idx >= 0 && data.action_summaries[idx].current_user) { - return; - } - - if (idx < 0) { - - // Add initial action when it doesn't exist. - data.action_summaries.push({ - __typename: `${capitalize(reaction)}ActionSummary`, - count: 0, - current_user: null - }); - idx = data.action_summaries.length - 1; - } - - data.action_summaries[idx] = { - ...data.action_summaries[idx], - count: data.action_summaries[idx].count + 1, - current_user: mutationResult.data[ - `create${capitalize(reaction)}` - ][reaction] - }; - - // Write our data back to the cache. - proxy.writeFragment({ - fragment: COMMENT_FRAGMENT, - id: fragmentId, - data - }); - } - }); - } - }) - } - ); - - const mapStateToProps = (state) => ({ - user: state.auth.toJS().user, - }); - - const mapDispatchToProps = (dispatch) => - bindActionCreators({showSignInDialog}, dispatch); - - const enhance = compose( - withFragments({ - comment: gql` - fragment ${capitalize(reaction)}Button_comment on Comment { - action_summaries { - ... on ${capitalize(reaction)}ActionSummary { - count - current_user { - id - } - } - } - }` - }), - connect(mapStateToProps, mapDispatchToProps), - withDeleteReaction, - withPostReaction - ); - - WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`; - - return enhance(WithReactions); -}; diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js index 70408248c..26d8e391f 100644 --- a/client/coral-framework/services/client.js +++ b/client/coral-framework/services/client.js @@ -1,7 +1,7 @@ import ApolloClient, {addTypename} from 'apollo-client'; import {networkInterface} from './transport'; import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws'; -import {SUBSCRIPTION_END} from 'subscriptions-transport-ws/dist/messageTypes'; +import MessageTypes from 'subscriptions-transport-ws/dist/message-types'; import {getAuthToken} from '../helpers/request'; let client, wsClient = null, wsClientToken = null; @@ -13,18 +13,17 @@ export function resetWebsocket() { return; } - // Unsubscribe from all the active subscriptions. - Object.keys(wsClient.subscriptions).forEach((id) => { - - // Create the message. - let message = {id: parseInt(id), type: SUBSCRIPTION_END}; - - // Send the unsubscribe message. - wsClient.client.send(JSON.stringify(message)); - }); - - // Close the client, this will trigger a reconnect. + // Close socket connection which will also unregister subscriptions on the server-side. wsClient.close(); + + // Reconnect to the server. + wsClient.connect(); + + // Reregister all subscriptions (uses non public api). + // See: https://github.com/apollographql/subscriptions-transport-ws/issues/171 + Object.keys(wsClient.operations).forEach((id) => { + wsClient.sendMessage(id, MessageTypes.GQL_START, wsClient.operations[id].options); + }); } export function getClient() { @@ -35,6 +34,7 @@ export function getClient() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; wsClient = new SubscriptionClient(`${protocol}://${location.host}/api/v1/live`, { reconnect: true, + lazy: true, connectionParams: { get token() { diff --git a/graph/mutators/action.js b/graph/mutators/action.js index 0d1cd49f8..de9efea1a 100644 --- a/graph/mutators/action.js +++ b/graph/mutators/action.js @@ -39,7 +39,7 @@ const createAction = async ({user = {}}, {item_id, item_type, action_type, group * @return {Promise} resolves when the action is deleted */ const deleteAction = ({user}, {id}) => { - return ActionModel.remove({ + return ActionModel.findOneAndRemove({ id, user_id: user.id }); diff --git a/graph/subscriptions.js b/graph/subscriptions.js index 0cc37e841..7d97d3521 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -50,7 +50,7 @@ const createSubscriptionManager = (server) => new SubscriptionServer({ connection.upgradeReq.headers['authorization'] = `Bearer ${token}`; } }, - onSubscribe: (parsedMessage, baseParams, connection) => { + onOperation: (parsedMessage, baseParams, connection) => { // Cache the upgrade request. let upgradeReq = connection.upgradeReq; @@ -58,7 +58,7 @@ const createSubscriptionManager = (server) => new SubscriptionServer({ // Attach the context per request. baseParams.context = async () => { let req; - + try { req = await deserializeUser(upgradeReq); } catch (e) { @@ -66,7 +66,7 @@ const createSubscriptionManager = (server) => new SubscriptionServer({ return new Context({}, pubsub); } - + return new Context(req, pubsub); }; diff --git a/package.json b/package.json index d78ba8713..f6a969de9 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "graphql-errors": "^2.1.0", "graphql-redis-subscriptions": "^1.1.5", "graphql-server-express": "^0.6.0", - "graphql-subscriptions": "^0.3.1", + "graphql-subscriptions": "^0.4.3", "graphql-tools": "^0.10.1", "helmet": "^3.5.0", "immutability-helper": "^2.2.0", @@ -116,7 +116,7 @@ "semver": "^5.3.0", "simplemde": "^1.11.2", "snake-case": "^2.1.0", - "subscriptions-transport-ws": "^0.5.5-alpha.0", + "subscriptions-transport-ws": "^0.7.2", "timekeeper": "^1.0.0", "uuid": "^3.0.1", "yaml-loader": "^0.4.0", diff --git a/plugin-api/beta/client/hocs/index.js b/plugin-api/beta/client/hocs/index.js index 7ec875656..7be682670 100644 --- a/plugin-api/beta/client/hocs/index.js +++ b/plugin-api/beta/client/hocs/index.js @@ -1 +1 @@ -export {withReaction} from 'coral-framework/hocs'; +export {default as withReaction} from './withReaction'; diff --git a/plugin-api/beta/client/hocs/withReaction.js b/plugin-api/beta/client/hocs/withReaction.js new file mode 100644 index 000000000..42ac79ec5 --- /dev/null +++ b/plugin-api/beta/client/hocs/withReaction.js @@ -0,0 +1,347 @@ +import React from 'react'; +import get from 'lodash/get'; +import uuid from 'uuid/v4'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {getDisplayName} from 'coral-framework/helpers/hoc'; +import {compose, gql} from 'react-apollo'; +import withFragments from 'coral-framework/hocs/withFragments'; +import withMutation from 'coral-framework/hocs/withMutation'; +import {showSignInDialog} from 'coral-framework/actions/auth'; +import {capitalize} from 'coral-framework/helpers/strings'; +import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils'; +import * as PropTypes from 'prop-types'; + +export default (reaction) => (WrappedComponent) => { + if (typeof reaction !== 'string') { + console.error('Reaction must be a valid string'); + return null; + } + + // Global instance counter for each `reaction` type. + let instances = 0; + + // Track current subscriptions. + let createdSubscription = null; + let deletedSubscription = null; + + reaction = reaction.toLowerCase(); + const Reaction = capitalize(reaction); + + const COMMENT_FRAGMENT = gql` + fragment ${Reaction}Button_updateFragment on Comment { + action_summaries { + ... on ${Reaction}ActionSummary { + count + current_user { + id + } + } + } + } + `; + + const isReaction = (a) => + a.__typename === `${Reaction}ActionSummary`; + + const addReactionToStore = (proxy, {action, self}) => { + const fragmentId = `Comment_${action.item_id}`; + + // Read the data from our cache for this query. + const data = proxy.readFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId + }); + + // Add our comment from the mutation to the end. + let idx = data.action_summaries.findIndex(isReaction); + + // Check whether we already reactioned this comment. + if (self && idx >= 0 && data.action_summaries[idx].current_user) { + return; + } + + if (idx < 0) { + + // Add initial action when it doesn't exist. + data.action_summaries.push({ + __typename: `${Reaction}ActionSummary`, + count: 0, + current_user: null + }); + idx = data.action_summaries.length - 1; + } + + data.action_summaries[idx] = { + ...data.action_summaries[idx], + count: data.action_summaries[idx].count + 1, + current_user: self ? action : data.action_summaries[idx].current_user + }; + + // Write our data back to the cache. + proxy.writeFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId, + data + }); + }; + + const deleteReactionFromStore = (proxy, {action, self}) => { + const fragmentId = `Comment_${action.item_id}`; + + // Read the data from our cache for this query. + const data = proxy.readFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId + }); + + // Check whether we liked this comment. + const idx = data.action_summaries.findIndex(isReaction); + + if ( + self && + (idx < 0 || get(data.action_summaries[idx], 'current_user.id') !== action.id) + ) { + return; + } + + data.action_summaries[idx] = { + ...data.action_summaries[idx], + count: data.action_summaries[idx].count - 1, + current_user: self ? null : data.action_summaries[idx].current_user, + }; + + // Write our data back to the cache. + proxy.writeFragment({ + fragment: COMMENT_FRAGMENT, + id: fragmentId, + data + }); + }; + + const REACTION_CREATED_SUBSCRIPTION = gql` + subscription ${Reaction}ActionCreated($assetId: ID!) { + ${reaction}ActionCreated(asset_id: $assetId) { + id + user { + id + } + item_id + } + } + `; + + const REACTION_DELETED_SUBSCRIPTION = gql` + subscription ${Reaction}ActionDeleted($assetId: ID!) { + ${reaction}ActionDeleted(asset_id: $assetId) { + id + user { + id + } + item_id + } + } + `; + + class WithReactions extends React.Component { + + static contextTypes = { + client: PropTypes.object.isRequired, + }; + + constructor(props, context) { + super(props, context); + + // Start subscriptions when it is first needed. + if (instances === 0) { + createdSubscription = context.client.subscribe({ + query: REACTION_CREATED_SUBSCRIPTION, + variables: { + assetId: this.props.root.asset.id, + }, + }).subscribe({ + next: this.onReactionCreated, + error(err) { console.error('err', err); }, + }); + + deletedSubscription = context.client.subscribe({ + query: REACTION_DELETED_SUBSCRIPTION, + variables: { + assetId: this.props.root.asset.id, + }, + }).subscribe({ + next: this.onReactionDeleted, + error(err) { console.error('err', err); }, + }); + } + instances++; + } + + // onReactionCreated handles live updates through the subscriptions. + onReactionCreated = ({[`${reaction}ActionCreated`]: action}) => { + if (this.props.user && action.user && this.props.user.id === action.user.id) { + return; + } + addReactionToStore(this.context.client, {action, self: false}); + }; + + // onReactionDeleted handles live updates through the subscriptions. + onReactionDeleted = ({[`${reaction}ActionDeleted`]: action}) => { + if (this.props.user && action.user && this.props.user.id === action.user.id) { + return; + } + deleteReactionFromStore(this.context.client, {action, self: false}); + }; + + componentWillUnmount() { + instances--; + + // End subscriptions when last component will be unmounted. + if (instances === 0) { + try { + createdSubscription.unsubscribe(); + deletedSubscription.unsubscribe(); + } + catch(e) { + console.warn(e); + } + } + } + + render() { + const {comment} = this.props; + + const reactionSummary = getMyActionSummary( + `${Reaction}ActionSummary`, + comment + ); + + const count = getTotalActionCount( + `${Reaction}ActionSummary`, + comment + ); + + const alreadyReacted = !!reactionSummary; + + const withReactionProps = {reactionSummary, count, alreadyReacted}; + + return ; + } + } + + const withDeleteReaction = withMutation( + gql` + mutation Delete${Reaction}Action($input: Delete${Reaction}ActionInput!) { + delete${Reaction}Action(input: $input) { + errors { + translation_key + } + } + } + `, + { + props: ({mutate, ownProps}) => ({ + deleteReaction: () => { + + const reactionSummary = getMyActionSummary( + `${Reaction}ActionSummary`, + ownProps.comment + ); + + const id = reactionSummary.current_user.id; + const item_id = ownProps.comment.id; + + const input = {id}; + return mutate({ + variables: {input}, + optimisticResponse: { + [`delete${Reaction}Action`]: { + __typename: `Delete${Reaction}ActionResponse`, + errors: null + } + }, + update: (proxy) => { + deleteReactionFromStore(proxy, {action: {item_id, id}, self: true}); + } + }); + } + }) + } + ); + + const withPostReaction = withMutation( + gql` + mutation Create${Reaction}Action($input: Create${Reaction}ActionInput!) { + create${Reaction}Action(input: $input) { + ${reaction} { + id + } + errors { + translation_key + } + } + } + `, + { + props: ({mutate, ownProps}) => ({ + postReaction: () => { + + const input = { + item_id: ownProps.comment.id, + }; + + return mutate({ + variables: {input}, + optimisticResponse: { + [`create${Reaction}Action`]: { + __typename: `Create${Reaction}ActionResponse`, + errors: null, + [reaction]: { + __typename: `${Reaction}Action`, + id: uuid() + } + } + }, + update: (proxy, {data: {[`create${Reaction}Action`]: {[reaction]: action}}}) => { + const a = { + ...action, + item_id: input.item_id, + }; + addReactionToStore(proxy, {action: a, self: true}); + } + }); + } + }) + } + ); + + const mapStateToProps = (state) => ({ + user: state.auth.toJS().user, + }); + + const mapDispatchToProps = (dispatch) => + bindActionCreators({showSignInDialog}, dispatch); + + const enhance = compose( + withFragments({ + comment: gql` + fragment ${Reaction}Button_comment on Comment { + action_summaries { + ... on ${Reaction}ActionSummary { + count + current_user { + id + } + } + } + }` + }), + connect(mapStateToProps, mapDispatchToProps), + withDeleteReaction, + withPostReaction + ); + + WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`; + + return enhance(WithReactions); +}; diff --git a/plugin-api/beta/server/getReactionConfig.js b/plugin-api/beta/server/getReactionConfig.js new file mode 100644 index 000000000..83c45dcb5 --- /dev/null +++ b/plugin-api/beta/server/getReactionConfig.js @@ -0,0 +1,192 @@ +const wrapResponse = require('../../../graph/helpers/response'); +const {SEARCH_OTHER_USERS} = require('../../../perms/constants'); + +function getReactionConfig(reaction) { + reaction = reaction.toLowerCase(); + + const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1); + const REACTION = reaction.toUpperCase(); + const typeDefs = ` + enum ACTION_TYPE { + + # Represents a ${Reaction}. + ${REACTION} + } + + enum ASSET_METRICS_SORT { + + # Represents a ${Reaction}Action. + ${REACTION} + } + + input Create${Reaction}ActionInput { + + # The item's id for which we are to create a ${reaction}. + item_id: ID! + } + + input Delete${Reaction}ActionInput { + + # The item's id for which we are deleting a ${reaction}. + id: ID! + } + + # ${Reaction}Action is used by users who "${reaction}" a specific entity. + type ${Reaction}Action implements Action { + + # The ID of the action. + id: ID! + + # The author of the action. + user: User + + # The time when the Action was updated. + updated_at: Date + + # The time when the Action was created. + created_at: Date + + # The item's id for which the Action was created. + item_id: ID! + } + + type ${Reaction}ActionSummary implements ActionSummary { + + # The count of actions with this group. + count: Int + + # The current user's action. + current_user: ${Reaction}Action + } + + # A summary of counts related to all the ${Reaction}s on an Asset. + type ${Reaction}AssetActionSummary implements AssetActionSummary { + + # Number of ${reaction}s associated with actionable types on this this Asset. + actionCount: Int + + # Number of unique actionable types that are referenced by the ${reaction}s. + actionableItemCount: Int + } + + type Create${Reaction}ActionResponse implements Response { + + # The ${reaction} that was created. + ${reaction}: ${Reaction}Action + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] + } + + type Delete${Reaction}ActionResponse implements Response { + + # The ${reaction} that was created. + ${reaction}: ${Reaction}Action + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] + } + + type RootMutation { + + # Creates a ${reaction} on an entity. + create${Reaction}Action(input: Create${Reaction}ActionInput!): Create${Reaction}ActionResponse + delete${Reaction}Action(input: Delete${Reaction}ActionInput!): Delete${Reaction}ActionResponse + } + + type Subscription { + + # Subscribe to ${reaction}s. + ${reaction}ActionCreated(asset_id: ID!): ${Reaction}Action + + # Subscribe to ${reaction} removals. + ${reaction}ActionDeleted(asset_id: ID!): ${Reaction}Action + } + `; + + return { + typeDefs, + resolvers: { + Subscription: { + [`${reaction}ActionCreated`]: ({action}) => { + return action; + }, + [`${reaction}ActionDeleted`]: ({action}) => { + return action; + }, + }, + [`${Reaction}Action`]: { + + // This will load the user for the specific action. We'll limit this to the + // admin users only or the current logged in user. + user({user_id}, _, {loaders: {Users}, user}) { + if (user && (user.can(SEARCH_OTHER_USERS) || user_id === user.id)) { + return Users.getByID.load(user_id); + } + } + }, + RootMutation: { + [`create${Reaction}Action`]: (_, {input: {item_id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => { + const response = Comments.get.load(item_id).then((comment) => { + return Action.create({item_id, item_type: 'COMMENTS', action_type: REACTION}) + .then((action) => { + + // The comment is needed to allow better filtering e.g. by asset_id. + pubsub.publish(`${reaction}ActionCreated`, {action, comment}); + return Promise.resolve(action); + }); + }); + return wrapResponse(reaction)(response); + }, + [`delete${Reaction}Action`]: (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => { + const response = Action.delete({id}) + .then((action) => { + return Comments.get.load(action.item_id).then((comment) => { + + // The comment is needed to allow better filtering e.g. by asset_id. + pubsub.publish(`${reaction}ActionDeleted`, {action, comment}); + return Promise.resolve(action); + }); + }); + return wrapResponse(reaction)(response); + } + }, + }, + hooks: { + Action: { + __resolveType: { + post({action_type}) { + switch (action_type) { + case REACTION: + return `${Reaction}Action`; + } + } + } + }, + ActionSummary: { + __resolveType: { + post({action_type}) { + switch (action_type) { + case REACTION: + return `${Reaction}ActionSummary`; + } + } + } + } + }, + setupFunctions: { + [`${reaction}ActionCreated`]: (options, args) => ({ + [`${reaction}ActionCreated`]: { + filter: ({comment}) => comment.asset_id === args.asset_id, + }, + }), + [`${reaction}ActionDeleted`]: (options, args) => ({ + [`${reaction}ActionDeleted`]: { + filter: ({comment}) => comment.asset_id === args.asset_id, + }, + }), + }, + }; +} + +module.exports = getReactionConfig; diff --git a/plugin-api/beta/server/index.js b/plugin-api/beta/server/index.js new file mode 100644 index 000000000..b32d78a45 --- /dev/null +++ b/plugin-api/beta/server/index.js @@ -0,0 +1,3 @@ +module.exports = { + getReactionConfig: require('./getReactionConfig'), +}; diff --git a/plugins.js b/plugins.js index f0c31407f..dfe3d3312 100644 --- a/plugins.js +++ b/plugins.js @@ -10,6 +10,7 @@ const PLUGINS_JSON = process.env.TALK_PLUGINS_JSON; // Add the current path to the module root. amp.addPath(__dirname); +let pluginsPath; let plugins = {}; // Try to parse the plugins.json file, logging out an error if the plugins.json @@ -22,14 +23,16 @@ try { if (PLUGINS_JSON && PLUGINS_JSON.length > 0) { debug('Now using TALK_PLUGINS_JSON environment variable for plugins'); - plugins = require(envPlugins); + pluginsPath = envPlugins; } else if (fs.existsSync(customPlugins)) { debug(`Now using ${customPlugins} for plugins`); - plugins = JSON.parse(fs.readFileSync(customPlugins, 'utf8')); + pluginsPath = customPlugins; } else { debug(`Now using ${defaultPlugins} for plugins`); - plugins = JSON.parse(fs.readFileSync(defaultPlugins, 'utf8')); + pluginsPath = defaultPlugins; } + + plugins = require(pluginsPath); } catch (err) { if (err.code === 'ENOENT') { console.error('plugins.json and plugins.default.json not found, plugins will not be active'); @@ -78,7 +81,12 @@ function isInternal(name) { */ function pluginPath(name) { if (isInternal(name)) { - return path.join(__dirname, 'plugins', name); + try { + return resolve.sync(name, {moduleDirectory: 'plugins', basedir: process.cwd()}); + } catch (e) { + console.warn(e); + return undefined; + } } try { @@ -88,15 +96,8 @@ function pluginPath(name) { } } -/** - * Itterates over the plugins and gets the plugin path's, version, and name. - * - * @param {Array} plugins - * @returns {Array} - */ -function itteratePlugins(plugins) { - return plugins.map((p) => { - let plugin = {}; +class Plugin { + constructor(entry) { // This checks to see if the structure for this entry is an object: // @@ -106,23 +107,47 @@ function itteratePlugins(plugins) { // // "people" // - if (typeof p === 'object') { - plugin.name = Object.keys(p).find((name) => name !== null); - plugin.version = p[plugin.name]; - } else if (typeof p === 'string') { - plugin.name = p; - plugin.version = `file:./plugins/${plugin.name}`; + if (typeof entry === 'object') { + this.name = Object.keys(entry).find((name) => name !== null); + this.version = entry[this.name]; + } else if (typeof entry === 'string') { + this.name = entry; + this.version = `file:./plugins/${this.name}`; } else { - throw new Error(`plugins.json is malformed, refer to PLUGINS.md for formatting, expected a string or an object for a plugin entry, found a ${typeof p}`); + throw new Error(`plugins.json is malformed, refer to PLUGINS.md for formatting, expected a string or an object for a plugin entry, found a ${typeof entry}`); } // Get the path for the plugin. - plugin.path = pluginPath(plugin.name); + this.path = pluginPath(this.name); + } - return plugin; - }); + require() { + if (typeof this.path === 'undefined') { + throw new Error(`plugin '${this.name}' is not local and is not resolvable, plugin reconsiliation may be required`); + } + + try { + this.module = require(this.path); + } catch (e) { + if (e && e.code && e.code === 'MODULE_NOT_FOUND' && isInternal(this.name)) { + console.error(new Error(`plugin '${this.name}' could not be loaded due to missing dependencies, plugin reconsiliation may be required`)); + throw e; + } + + console.error(new Error(`plugin '${this.name}' could not be required from '${this.path}': ${e.message}`)); + throw e; + } + } } +/** + * Itterates over the plugins and gets the plugin path's, version, and name. + * + * @param {Array} plugins + * @returns {Array} + */ +const itteratePlugins = (plugins) => plugins.map((p) => new Plugin(p)); + // 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) => { @@ -139,22 +164,20 @@ Object.keys(plugins).forEach((type) => itteratePlugins(plugins[type]).forEach((p */ class PluginSection { constructor(plugins) { - this.plugins = itteratePlugins(plugins).map((plugin) => { - if (typeof plugin.path === 'undefined') { - throw new Error(`plugin '${plugin.name}' is not local and is not resolvable, plugin reconsiliation may be required`); - } + this.required = false; + this.plugins = itteratePlugins(plugins); + } - try { - plugin.module = require(plugin.path); - } catch (e) { - if (e && e.code && e.code === 'MODULE_NOT_FOUND' && isInternal(plugin.name)) { - console.error(new Error(`plugin '${plugin.name}' could not be loaded due to missing dependencies, plugin reconsiliation may be required`)); - throw e; - } + require() { + if (this.required) { + return; + } + + this.required = true; + this.plugins.forEach((plugin) => { - console.error(new Error(`plugin '${plugin.name}' could not be required from '${plugin.path}': ${e.message}`)); - throw e; - } + // Load the plugin. + plugin.require(); if (isInternal(plugin.name)) { debug(`loading internal plugin '${plugin.name}' from '${plugin.path}'`); @@ -171,6 +194,10 @@ class PluginSection { * available. */ hook(hook) { + + // Load the plugin source if we haven't already. + this.require(); + return this.plugins .filter(({module}) => hook in module) .filter((plugin) => { @@ -226,6 +253,7 @@ class PluginManager { module.exports = { plugins, + pluginsPath, PluginManager, isInternal, pluginPath, diff --git a/plugins/coral-plugin-like/client/LikeButton.js b/plugins/coral-plugin-like/client/LikeButton.js new file mode 100644 index 000000000..5b99f7df4 --- /dev/null +++ b/plugins/coral-plugin-like/client/LikeButton.js @@ -0,0 +1,55 @@ +import React from 'react'; +import styles from './styles.css'; +import {withReaction} from 'plugin-api/beta/client/hocs'; +import {t, can} from 'plugin-api/beta/client/services'; +import {Icon} from 'plugin-api/beta/client/components'; +import cn from 'classnames'; + +const plugin = 'coral-plugin-like'; + +class LikeButton extends React.Component { + handleClick = () => { + const { + postReaction, + deleteReaction, + showSignInDialog, + alreadyReacted, + user, + } = this.props; + + // If the current user does not exist, trigger sign in dialog. + if (!user) { + showSignInDialog(); + return; + } + + // If the current user is suspended, do nothing. + if (!can(user, 'INTERACT_WITH_COMMUNITY')) { + return; + } + + if (alreadyReacted) { + deleteReaction(); + } else { + postReaction(); + } + }; + + render() { + const {count, alreadyReacted} = this.props; + return ( + + + {t(alreadyReacted ? 'coral-plugin-like.liked' : 'coral-plugin-like.like')} + + {count > 0 && count} + + + ); + } +} + +export default withReaction('like')(LikeButton); diff --git a/plugins/coral-plugin-like/client/components/LikeButton.js b/plugins/coral-plugin-like/client/components/LikeButton.js deleted file mode 100644 index 7b66a11d4..000000000 --- a/plugins/coral-plugin-like/client/components/LikeButton.js +++ /dev/null @@ -1,86 +0,0 @@ -import React, {Component} from 'react'; -import styles from './style.css'; - -import cn from 'classnames'; -import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils'; -import t from 'coral-framework/services/i18n'; - -const name = 'coral-plugin-like'; - -class LikeButton extends Component { - handleClick = () => { - const {postLike, showSignInDialog, deleteAction} = this.props; - const {root: {me}, comment} = this.props; - - const myLikeActionSummary = getMyActionSummary( - 'LikeActionSummary', - comment - ); - - // If the current user does not exist, trigger sign in dialog. - if (!me) { - showSignInDialog(); - return; - } - - // If the current user is banned, do nothing. - if (me.status === 'BANNED') { - return; - } - - if (myLikeActionSummary) { - deleteAction(myLikeActionSummary.current_user.id, comment.id); - } else { - postLike({ - item_id: comment.id, - item_type: 'COMMENTS' - }); - } - }; - - render() { - const {comment} = this.props; - - if (!comment) { - return null; - } - - const myLike = getMyActionSummary('LikeActionSummary', comment); - let count = getTotalActionCount('LikeActionSummary', comment); - - return ( - - - - {t(myLike ? 'liked' : 'like')} - - - thumb_up - - {count > 0 && count} - - - ); - } -} - -LikeButton.propTypes = { - data: React.PropTypes.object.isRequired -}; - -export default LikeButton; diff --git a/plugins/coral-plugin-like/client/containers/LikeButton.js b/plugins/coral-plugin-like/client/containers/LikeButton.js deleted file mode 100644 index 51bee1b40..000000000 --- a/plugins/coral-plugin-like/client/containers/LikeButton.js +++ /dev/null @@ -1,186 +0,0 @@ -import get from 'lodash/get'; -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import {compose, gql, graphql} from 'react-apollo'; -import LikeButton from '../components/LikeButton'; -import withFragments from 'coral-framework/hocs/withFragments'; -import{showSignInDialog} from 'coral-framework/actions/auth'; - -const isLikeAction = (a) => a.__typename === 'LikeActionSummary'; - -const COMMENT_FRAGMENT = gql` - fragment LikeButton_updateFragment on Comment { - action_summaries { - ... on LikeActionSummary { - count - current_user { - id - } - } - } - } -`; - -const withDeleteAction = graphql( - gql` - mutation deleteAction($id: ID!) { - deleteAction(id:$id) { - errors { - translation_key - } - } - } -`, - { - props: ({mutate}) => ({ - deleteAction: (id, commentId) => { - return mutate({ - variables: {id}, - optimisticResponse: { - deleteAction: { - __typename: 'DeleteActionResponse', - errors: null - } - }, - update: (proxy) => { - const fragmentId = `Comment_${commentId}`; - - // Read the data from our cache for this query. - const data = proxy.readFragment({ - fragment: COMMENT_FRAGMENT, - id: fragmentId - }); - - // Check whether we liked this comment. - const idx = data.action_summaries.findIndex(isLikeAction); - if ( - idx < 0 || - get(data.action_summaries[idx], 'current_user.id') !== id - ) { - return; - } - - data.action_summaries[idx] = { - ...data.action_summaries[idx], - count: data.action_summaries[idx].count - 1, - current_user: null - }; - - // Write our data back to the cache. - proxy.writeFragment({ - fragment: COMMENT_FRAGMENT, - id: fragmentId, - data - }); - } - }); - } - }) - } -); - -const withPostLike = graphql( - gql` - mutation createLike($like: CreateLikeInput!) { - createLike(like: $like) { - like { - id - } - errors { - translation_key - } - } - } -`, - { - props: ({mutate}) => ({ - postLike: (like) => { - return mutate({ - variables: {like}, - optimisticResponse: { - createLike: { - __typename: 'CreateLikeResponse', - errors: null, - like: { - __typename: 'LikeAction', - id: 'pending' - } - } - }, - update: (proxy, mutationResult) => { - const fragmentId = `Comment_${like.item_id}`; - - // Read the data from our cache for this query. - const data = proxy.readFragment({ - fragment: COMMENT_FRAGMENT, - id: fragmentId - }); - - // Add our comment from the mutation to the end. - let idx = data.action_summaries.findIndex(isLikeAction); - - // Check whether we already liked this comment. - if (idx >= 0 && data.action_summaries[idx].current_user) { - return; - } - - if (idx < 0) { - - // Add initial action when it doesn't exist. - data.action_summaries.push({ - __typename: 'LikeActionSummary', - count: 0, - current_user: null - }); - idx = data.action_summaries.length - 1; - } - - data.action_summaries[idx] = { - ...data.action_summaries[idx], - count: data.action_summaries[idx].count + 1, - current_user: mutationResult.data.createLike.like - }; - - // Write our data back to the cache. - proxy.writeFragment({ - fragment: COMMENT_FRAGMENT, - id: fragmentId, - data - }); - } - }); - } - }) - } -); - -const mapDispatchToProps = (dispatch) => - bindActionCreators({showSignInDialog}, dispatch); - -const enhance = compose( - withFragments({ - root: gql` - fragment LikeButton_root on RootQuery { - me { - status - } - } - `, - comment: gql` - fragment LikeButton_comment on Comment { - action_summaries { - ... on LikeActionSummary { - count - current_user { - id - } - } - } - }` - }), - connect(null, mapDispatchToProps), - withDeleteAction, - withPostLike -); - -export default enhance(LikeButton); diff --git a/plugins/coral-plugin-like/client/index.js b/plugins/coral-plugin-like/client/index.js index 86d20863a..68b7a2c46 100644 --- a/plugins/coral-plugin-like/client/index.js +++ b/plugins/coral-plugin-like/client/index.js @@ -1,5 +1,5 @@ -import LikeButton from './containers/LikeButton'; -import translations from './translations.json'; +import LikeButton from './LikeButton'; +import translations from './translations.yml'; export default { translations, diff --git a/plugins/coral-plugin-like/client/components/style.css b/plugins/coral-plugin-like/client/styles.css similarity index 89% rename from plugins/coral-plugin-like/client/components/style.css rename to plugins/coral-plugin-like/client/styles.css index f45df86ef..cb372fa47 100644 --- a/plugins/coral-plugin-like/client/components/style.css +++ b/plugins/coral-plugin-like/client/styles.css @@ -1,6 +1,6 @@ -.like { +.container { display: inline-block; - } +} .button { color: #2a2a2a; @@ -17,14 +17,9 @@ &.liked { color: rgb(0,134,227); - &:hover { color: rgb(0,134,227); cursor: pointer; } } } - -.icon { - padding: 0 5px; -} diff --git a/plugins/coral-plugin-like/client/translations.json b/plugins/coral-plugin-like/client/translations.json deleted file mode 100644 index 93d73d3a2..000000000 --- a/plugins/coral-plugin-like/client/translations.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "en": { - "like": "Like", - "liked": "Liked" - }, - "es": { - "like": "Me Gusta", - "liked": "Me Gustó" - } -} diff --git a/plugins/coral-plugin-like/client/translations.yml b/plugins/coral-plugin-like/client/translations.yml new file mode 100644 index 000000000..c534663af --- /dev/null +++ b/plugins/coral-plugin-like/client/translations.yml @@ -0,0 +1,9 @@ +en: + coral-plugin-like: + like: Like + liked: Liked +es: + coral-plugin-like: + like: Me Gusta + liked: Me Gustó + diff --git a/plugins/coral-plugin-like/index.js b/plugins/coral-plugin-like/index.js index 4fc0285f4..690577bed 100644 --- a/plugins/coral-plugin-like/index.js +++ b/plugins/coral-plugin-like/index.js @@ -1,36 +1,2 @@ -const {readFileSync} = require('fs'); -const path = require('path'); -const wrapResponse = require('../../graph/helpers/response'); - -module.exports = { - typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8'), - resolvers: { - RootMutation: { - createLike(_, {like: {item_id, item_type}}, {mutators: {Action}}) { - return wrapResponse('like')(Action.create({item_id, item_type, action_type: 'LIKE'})); - } - } - }, - hooks: { - Action: { - __resolveType: { - post({action_type}) { - switch (action_type) { - case 'LIKE': - return 'LikeAction'; - } - } - } - }, - ActionSummary: { - __resolveType: { - post({action_type}) { - switch (action_type) { - case 'LIKE': - return 'LikeActionSummary'; - } - } - } - } - } -}; +const {getReactionConfig} = require('../../plugin-api/beta/server'); +module.exports = getReactionConfig('like'); diff --git a/plugins/coral-plugin-like/server/typeDefs.graphql b/plugins/coral-plugin-like/server/typeDefs.graphql deleted file mode 100644 index 40c600f2f..000000000 --- a/plugins/coral-plugin-like/server/typeDefs.graphql +++ /dev/null @@ -1,70 +0,0 @@ -enum ACTION_TYPE { - - # Represents a Like. - LIKE -} - -enum ASSET_METRICS_SORT { - - # Represents a LikeAction. - LIKE -} - -input CreateLikeInput { - - # The item's id for which we are to create a like. - item_id: ID! - - # The type of the item for which we are to create the like. - item_type: ACTION_ITEM_TYPE! -} - -# LikeAction is used by users who "like" a specific entity. -type LikeAction implements Action { - - # The ID of the action. - id: ID! - - # The author of the action. - user: User - - # The time when the Action was updated. - updated_at: Date - - # The time when the Action was created. - created_at: Date -} - -type LikeActionSummary implements ActionSummary { - - # The count of actions with this group. - count: Int - - # The current user's action. - current_user: LikeAction -} - -# A summary of counts related to all the Likes on an Asset. -type LikeAssetActionSummary implements AssetActionSummary { - - # Number of likes associated with actionable types on this this Asset. - actionCount: Int - - # Number of unique actionable types that are referenced by the likes. - actionableItemCount: Int -} - -type CreateLikeResponse implements Response { - - # The like that was created. - like: LikeAction - - # An array of errors relating to the mutation that occurred. - errors: [UserError!] -} - -type RootMutation { - - # Creates a like on an entity. - createLike(like: CreateLikeInput!): CreateLikeResponse -} diff --git a/plugins/coral-plugin-love/client/LoveButton.js b/plugins/coral-plugin-love/client/LoveButton.js index de4c4c9e7..0e2b9ff01 100644 --- a/plugins/coral-plugin-love/client/LoveButton.js +++ b/plugins/coral-plugin-love/client/LoveButton.js @@ -1,8 +1,11 @@ import React from 'react'; -import {Icon} from 'coral-ui'; import styles from './styles.css'; import {withReaction} from 'plugin-api/beta/client/hocs'; import {t, can} from 'plugin-api/beta/client/services'; +import {Icon} from 'plugin-api/beta/client/components'; +import cn from 'classnames'; + +const plugin = 'coral-plugin-love'; class LoveButton extends React.Component { handleClick = () => { @@ -25,7 +28,7 @@ class LoveButton extends React.Component { return; } - if (alreadyReacted()) { + if (alreadyReacted) { deleteReaction(); } else { postReaction(); @@ -35,14 +38,16 @@ class LoveButton extends React.Component { render() { const {count, alreadyReacted} = this.props; return ( - - {t(alreadyReacted() ? 'loved' : 'love')} - - {count > 0 && count} - + + + {t(alreadyReacted ? 'coral-plugin-love.loved' : 'coral-plugin-love.love')} + + {count > 0 && count} + + ); } } diff --git a/plugins/coral-plugin-love/client/index.js b/plugins/coral-plugin-love/client/index.js index fa2f71159..fd7174d81 100644 --- a/plugins/coral-plugin-love/client/index.js +++ b/plugins/coral-plugin-love/client/index.js @@ -1,5 +1,5 @@ import LoveButton from './LoveButton'; -import translations from './translations.json'; +import translations from './translations.yml'; export default { translations, diff --git a/plugins/coral-plugin-love/client/styles.css b/plugins/coral-plugin-love/client/styles.css index d48e7e28c..e16e17ca4 100644 --- a/plugins/coral-plugin-love/client/styles.css +++ b/plugins/coral-plugin-love/client/styles.css @@ -1,26 +1,25 @@ -.respect { - display: inline-block; +.container { + display: inline-block; } .button { - color: #2a2a2a; - margin: 5px 10px 5px 0px; - background: none; - padding: 0px; - border: none; - font-size: inherit; + color: #2a2a2a; + margin: 5px 10px 5px 0px; + background: none; + padding: 0px; + border: none; + font-size: inherit; + &:hover { + color: #767676; + cursor: pointer; + } + + &.loved { + color: #e52338; &:hover { - color: #767676; - cursor: pointer; - } - - &.loved { - color: #e52338; - - &:hover { - color: #e52839; - cursor: pointer; - } + color: #e52839; + cursor: pointer; } + } } diff --git a/plugins/coral-plugin-love/client/translations.json b/plugins/coral-plugin-love/client/translations.json deleted file mode 100644 index a015efa77..000000000 --- a/plugins/coral-plugin-love/client/translations.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "en": { - "love": "Love", - "loved": "Loved" - }, - "es": { - "love": "Amo", - "loved": "Amé" - } -} diff --git a/plugins/coral-plugin-love/client/translations.yml b/plugins/coral-plugin-love/client/translations.yml new file mode 100644 index 000000000..06b6e5257 --- /dev/null +++ b/plugins/coral-plugin-love/client/translations.yml @@ -0,0 +1,9 @@ +en: + coral-plugin-love: + love: Love + loved: Loved +es: + coral-plugin-love: + love: Amo + loved: Amé + diff --git a/plugins/coral-plugin-love/index.js b/plugins/coral-plugin-love/index.js index 543c2a099..b57fdec39 100644 --- a/plugins/coral-plugin-love/index.js +++ b/plugins/coral-plugin-love/index.js @@ -1,36 +1,2 @@ -const {readFileSync} = require('fs'); -const path = require('path'); -const wrapResponse = require('../../graph/helpers/response'); - -module.exports = { - typeDefs: readFileSync(path.join(__dirname, 'server/typeDefs.graphql'), 'utf8'), - resolvers: { - RootMutation: { - createLove(_, {love: {item_id, item_type}}, {mutators: {Action}}) { - return wrapResponse('love')(Action.create({item_id, item_type, action_type: 'LOVE'})); - } - } - }, - hooks: { - Action: { - __resolveType: { - post({action_type}) { - switch (action_type) { - case 'LOVE': - return 'LoveAction'; - } - } - } - }, - ActionSummary: { - __resolveType: { - post({action_type}) { - switch (action_type) { - case 'LOVE': - return 'LoveActionSummary'; - } - } - } - } - } -}; +const {getReactionConfig} = require('../../plugin-api/beta/server'); +module.exports = getReactionConfig('love'); diff --git a/plugins/coral-plugin-love/server/typeDefs.graphql b/plugins/coral-plugin-love/server/typeDefs.graphql deleted file mode 100644 index edc45e20b..000000000 --- a/plugins/coral-plugin-love/server/typeDefs.graphql +++ /dev/null @@ -1,70 +0,0 @@ -enum ACTION_TYPE { - - # Represents a Love. - LOVE -} - -enum ASSET_METRICS_SORT { - - # Represents a LoveAction. - LOVE -} - -input CreateLoveInput { - - # The item's id for which we are to create a love. - item_id: ID! - - # The type of the item for which we are to create the love. - item_type: ACTION_ITEM_TYPE! -} - -# LoveAction is used by users who "love" a specific entity. -type LoveAction implements Action { - - # The ID of the action. - id: ID! - - # The author of the action. - user: User - - # The time when the Action was updated. - updated_at: Date - - # The time when the Action was created. - created_at: Date -} - -type LoveActionSummary implements ActionSummary { - - # The count of actions with this group. - count: Int - - # The current user's action. - current_user: LoveAction -} - -# A summary of counts related to all the Loves on an Asset. -type LoveAssetActionSummary implements AssetActionSummary { - - # Number of loves associated with actionable types on this this Asset. - actionCount: Int - - # Number of unique actionable types that are referenced by the loves. - actionableItemCount: Int -} - -type CreateLoveResponse implements Response { - - # The love that was created. - love: LoveAction - - # An array of errors relating to the mutation that occurred. - errors: [UserError!] -} - -type RootMutation { - - # Creates a love on an entity. - createLove(love: CreateLoveInput!): CreateLoveResponse -} diff --git a/plugins/coral-plugin-like/client/components/Icon.js b/plugins/coral-plugin-respect/client/Icon.js similarity index 100% rename from plugins/coral-plugin-like/client/components/Icon.js rename to plugins/coral-plugin-respect/client/Icon.js diff --git a/plugins/coral-plugin-respect/client/RespectButton.js b/plugins/coral-plugin-respect/client/RespectButton.js new file mode 100644 index 000000000..bde8c9669 --- /dev/null +++ b/plugins/coral-plugin-respect/client/RespectButton.js @@ -0,0 +1,57 @@ +import React from 'react'; +import Icon from './Icon'; +import styles from './styles.css'; +import {withReaction} from 'plugin-api/beta/client/hocs'; +import {t, can} from 'plugin-api/beta/client/services'; +import cn from 'classnames'; + +const plugin = 'coral-plugin-respect'; + +class RespectButton extends React.Component { + handleClick = () => { + const { + postReaction, + deleteReaction, + showSignInDialog, + alreadyReacted, + user, + } = this.props; + + // If the current user does not exist, trigger sign in dialog. + if (!user) { + showSignInDialog(); + return; + } + + // If the current user is suspended, do nothing. + if (!can(user, 'INTERACT_WITH_COMMUNITY')) { + return; + } + + if (alreadyReacted) { + deleteReaction(); + } else { + postReaction(); + } + }; + + render() { + const {count, alreadyReacted} = this.props; + return ( + + + + {t(alreadyReacted ? 'coral-plugin-respect.respected' : 'coral-plugin-respect.respect')} + + + {count > 0 && count} + + + ); + } +} + +export default withReaction('respect')(RespectButton); diff --git a/plugins/coral-plugin-respect/client/components/Icon.js b/plugins/coral-plugin-respect/client/components/Icon.js deleted file mode 100644 index c24841e97..000000000 --- a/plugins/coral-plugin-respect/client/components/Icon.js +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; -import cn from 'classnames'; - -export default ({className}) => ( - -); diff --git a/plugins/coral-plugin-respect/client/components/RespectButton.js b/plugins/coral-plugin-respect/client/components/RespectButton.js deleted file mode 100644 index 58bb35e9c..000000000 --- a/plugins/coral-plugin-respect/client/components/RespectButton.js +++ /dev/null @@ -1,69 +0,0 @@ -import React, {Component} from 'react'; -import styles from './style.css'; -import Icon from './Icon'; - -import t from 'coral-framework/services/i18n'; -import cn from 'classnames'; - -import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils'; - -const name = 'coral-plugin-respect'; - -class RespectButton extends Component { - - handleClick = () => { - const {postRespect, showSignInDialog, deleteAction} = this.props; - const {root: {me}, comment} = this.props; - - const myRespectActionSummary = getMyActionSummary('RespectActionSummary', comment); - - // If the current user does not exist, trigger sign in dialog. - if (!me) { - showSignInDialog(); - return; - } - - // If the current user is banned, do nothing. - if (me.status === 'BANNED') { - return; - } - - if (myRespectActionSummary) { - deleteAction(myRespectActionSummary.current_user.id, comment.id); - } else { - postRespect({ - item_id: comment.id, - item_type: 'COMMENTS' - }); - } - } - - render() { - const {comment} = this.props; - - if (!comment) { - return null; - } - - const myRespect = getMyActionSummary('RespectActionSummary', comment); - let count = getTotalActionCount('RespectActionSummary', comment); - - return ( - - - {t(myRespect ? 'respected' : 'respect')} - - {count > 0 && count} - - - ); - } -} - -RespectButton.propTypes = { - data: React.PropTypes.object.isRequired -}; - -export default RespectButton; diff --git a/plugins/coral-plugin-respect/client/containers/RespectButton.js b/plugins/coral-plugin-respect/client/containers/RespectButton.js deleted file mode 100644 index 296ca9a1f..000000000 --- a/plugins/coral-plugin-respect/client/containers/RespectButton.js +++ /dev/null @@ -1,163 +0,0 @@ -import {compose, gql} from 'react-apollo'; -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import get from 'lodash/get'; -import {withFragments, withMutation} from 'coral-framework/hocs'; -import {showSignInDialog} from 'coral-framework/actions/auth'; -import RespectButton from '../components/RespectButton'; - -const isRespectAction = (a) => a.__typename === 'RespectActionSummary'; - -const COMMENT_FRAGMENT = gql` - fragment CoralRespect_UpdateFragment on Comment { - action_summaries { - ... on RespectActionSummary { - count - current_user { - id - } - } - } - } -`; - -const withDeleteAction = withMutation(gql` - mutation CoralRespect_DeleteAction($id: ID!) { - deleteAction(id:$id) { - errors { - translation_key - } - } - } -`, { - props: ({mutate}) => ({ - deleteAction: (id, commentId) => { - return mutate({ - variables: {id}, - optimisticResponse: { - deleteAction: { - __typename: 'DeleteActionResponse', - errors: null, - } - }, - update: (proxy) => { - const fragmentId = `Comment_${commentId}`; - - // Read the data from our cache for this query. - const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId}); - - // Check whether we respected this comment. - const idx = data.action_summaries.findIndex(isRespectAction); - if (idx < 0 || get(data.action_summaries[idx], 'current_user.id') !== id) { - return; - } - - data.action_summaries[idx] = { - ...data.action_summaries[idx], - count: data.action_summaries[idx].count - 1, - current_user: null, - }; - - // Write our data back to the cache. - proxy.writeFragment({fragment: COMMENT_FRAGMENT, id: fragmentId, data}); - }, - }); - }, - }), -}); - -const withPostRespect = withMutation(gql` - mutation CoralRespect_CreateRespect($respect: CreateRespectInput!) { - createRespect(respect: $respect) { - respect { - id - } - errors { - translation_key - } - } - } -`, { - props: ({mutate}) => ({ - postRespect: (respect) => { - return mutate({ - variables: {respect}, - optimisticResponse: { - createRespect: { - __typename: 'CreateRespectResponse', - errors: null, - respect: { - __typename: 'RespectAction', - id: 'pending', - }, - } - }, - update: (proxy, mutationResult) => { - const fragmentId = `Comment_${respect.item_id}`; - - // Read the data from our cache for this query. - const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId}); - - // Add our comment from the mutation to the end. - let idx = data.action_summaries.findIndex(isRespectAction); - - // Check whether we already respected this comment. - if (idx >= 0 && data.action_summaries[idx].current_user) { - return; - } - - if (idx < 0) { - - // Add initial action when it doesn't exist. - data.action_summaries.push({ - __typename: 'RespectActionSummary', - count: 0, - current_user: null, - }); - idx = data.action_summaries.length - 1; - } - - data.action_summaries[idx] = { - ...data.action_summaries[idx], - count: data.action_summaries[idx].count + 1, - current_user: mutationResult.data.createRespect.respect, - }; - - // Write our data back to the cache. - proxy.writeFragment({fragment: COMMENT_FRAGMENT, id: fragmentId, data}); - }, - }); - }, - }), -}); - -const mapDispatchToProps = (dispatch) => - bindActionCreators({showSignInDialog}, dispatch); - -const enhance = compose( - withFragments({ - root: gql` - fragment CoralRespect_RespectButton_root on RootQuery { - me { - status - } - } - `, - comment: gql` - fragment CoralRespect_RespectButton_comment on Comment { - action_summaries { - ... on RespectActionSummary { - count - current_user { - id - } - } - } - }`, - }), - connect(null, mapDispatchToProps), - withDeleteAction, - withPostRespect, -); - -export default enhance(RespectButton); diff --git a/plugins/coral-plugin-respect/client/index.js b/plugins/coral-plugin-respect/client/index.js index 4df7c66ec..0a1b58a0a 100644 --- a/plugins/coral-plugin-respect/client/index.js +++ b/plugins/coral-plugin-respect/client/index.js @@ -1,9 +1,9 @@ -import RespectButton from './containers/RespectButton'; -import translations from './translations.json'; +import RespectButton from './RespectButton'; +import translations from './translations.yml'; export default { translations, slots: { - commentActions: [RespectButton], + commentReactions: [RespectButton] } }; diff --git a/plugins/coral-plugin-respect/client/components/style.css b/plugins/coral-plugin-respect/client/styles.css similarity index 95% rename from plugins/coral-plugin-respect/client/components/style.css rename to plugins/coral-plugin-respect/client/styles.css index 32f9a8959..f0b780b86 100644 --- a/plugins/coral-plugin-respect/client/components/style.css +++ b/plugins/coral-plugin-respect/client/styles.css @@ -1,6 +1,6 @@ -.respect { +.container { display: inline-block; - } +} .button { color: #2a2a2a; diff --git a/plugins/coral-plugin-respect/client/translations.json b/plugins/coral-plugin-respect/client/translations.json deleted file mode 100644 index a74e04b3f..000000000 --- a/plugins/coral-plugin-respect/client/translations.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "en": { - "respect": "Respect", - "respected": "Respected" - }, - "es": { - "respect": "Respetar", - "respected": "Respetado" - } -} diff --git a/plugins/coral-plugin-respect/client/translations.yml b/plugins/coral-plugin-respect/client/translations.yml new file mode 100644 index 000000000..f30ed5719 --- /dev/null +++ b/plugins/coral-plugin-respect/client/translations.yml @@ -0,0 +1,9 @@ +en: + coral-plugin-respect: + respect: Respect + respected: Respected +es: + coral-plugin-respect: + respect: Respetar + respected: Respetado + diff --git a/plugins/coral-plugin-respect/index.js b/plugins/coral-plugin-respect/index.js index 019f0ef11..97f0e5a7c 100644 --- a/plugins/coral-plugin-respect/index.js +++ b/plugins/coral-plugin-respect/index.js @@ -1,41 +1,2 @@ -const {readFileSync} = require('fs'); -const path = require('path'); -const wrapResponse = require('../../graph/helpers/response'); - -module.exports = { - typeDefs: readFileSync( - path.join(__dirname, 'server/typeDefs.graphql'), - 'utf8' - ), - resolvers: { - RootMutation: { - createRespect(_, {respect: {item_id, item_type}}, {mutators: {Action}}) { - return wrapResponse('respect')( - Action.create({item_id, item_type, action_type: 'RESPECT'}) - ); - } - } - }, - hooks: { - Action: { - __resolveType: { - post({action_type}) { - switch (action_type) { - case 'RESPECT': - return 'RespectAction'; - } - } - } - }, - ActionSummary: { - __resolveType: { - post({action_type}) { - switch (action_type) { - case 'RESPECT': - return 'RespectActionSummary'; - } - } - } - } - } -}; +const {getReactionConfig} = require('../../plugin-api/beta/server'); +module.exports = getReactionConfig('respect'); diff --git a/plugins/coral-plugin-respect/server/typeDefs.graphql b/plugins/coral-plugin-respect/server/typeDefs.graphql deleted file mode 100644 index 56734e543..000000000 --- a/plugins/coral-plugin-respect/server/typeDefs.graphql +++ /dev/null @@ -1,54 +0,0 @@ -enum ACTION_TYPE { - - # Represents a Respect. - RESPECT -} - -input CreateRespectInput { - - # The item's id for which we are to create a respect. - item_id: ID! - - # The type of the item for which we are to create the respect. - item_type: ACTION_ITEM_TYPE! -} - -# RespectAction is used by users who "respect" a specific entity. -type RespectAction implements Action { - - # The ID of the action. - id: ID! - - # The author of the action. - user: User - - # The time when the Action was updated. - updated_at: Date - - # The time when the Action was created. - created_at: Date -} - -type RespectActionSummary implements ActionSummary { - - # The count of actions with this group. - count: Int - - # The current user's action. - current_user: RespectAction -} - -type CreateRespectResponse implements Response { - - # The respect that was created. - respect: RespectAction - - # An array of errors relating to the mutation that occurred. - errors: [UserError!] -} - -type RootMutation { - - # Creates a respect on an entity. - createRespect(respect: CreateRespectInput!): CreateRespectResponse -} diff --git a/webpack.config.js b/webpack.config.js index ce1e0bd29..130ced27b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,6 +3,7 @@ const fs = require('fs'); const CompressionPlugin = require('compression-webpack-plugin'); const autoprefixer = require('autoprefixer'); const precss = require('precss'); +const _ = require('lodash'); const Copy = require('copy-webpack-plugin'); const LicenseWebpackPlugin = require('license-webpack-plugin'); const webpack = require('webpack'); @@ -10,23 +11,11 @@ const webpack = require('webpack'); // Possibly load the config from the .env file (if there is one). require('dotenv').config(); -let pluginsConfigPath; +const {plugins, pluginsPath, PluginManager} = require('./plugins'); +const manager = new PluginManager(plugins); +const targetPlugins = manager.section('targets').plugins; -let envPlugins = path.join(__dirname, 'plugins.env.js'); -let customPlugins = path.join(__dirname, 'plugins.json'); -let defaultPlugins = path.join(__dirname, 'plugins.default.json'); - -if (process.env.TALK_PLUGINS_JSON && process.env.TALK_PLUGINS_JSON.length > 0) { - pluginsConfigPath = envPlugins; -} else if (fs.existsSync(customPlugins)) { - pluginsConfigPath = customPlugins; -} else { - pluginsConfigPath = defaultPlugins; -} - -console.log(`Using ${pluginsConfigPath} as the plugin configuration path`); - -// Edit the build targets and embeds below. +console.log(`Using ${pluginsPath} as the plugin configuration path`); const buildTargets = [ 'coral-admin', @@ -37,47 +26,23 @@ const buildEmbeds = [ 'stream' ]; +//============================================================================== +// Base Webpack Config +//============================================================================== + const config = { devtool: 'cheap-module-source-map', - entry: Object.assign({}, { - 'embed': [ - 'babel-polyfill', - path.join(__dirname, 'client/coral-embed/src/index') - ] - }, buildTargets.reduce((entry, target) => { - - // Add the entry for the bundle. - entry[`${target}/bundle`] = [ - 'babel-polyfill', - path.join(__dirname, 'client/', target, '/src/index') - ]; - - return entry; - }, {}), buildEmbeds.reduce((entry, embed) => { - - // Add the entry for the bundle. - entry[`embed/${embed}/bundle`] = [ - 'babel-polyfill', - path.join(__dirname, 'client/', `coral-embed-${embed}`, '/src/index') - ]; - - return entry; - }, {})), output: { path: path.join(__dirname, 'dist'), publicPath: '/client/', - filename: '[name].js', - - // NOTE: this causes all exports to override the global.Coral, so no more - // than one bundle.js can be included on a page. - library: 'Coral' + filename: '[name].js' }, module: { rules: [ { loader: 'plugins-loader', test: /\.(json|js)$/, - include: pluginsConfigPath + include: pluginsPath }, { loader: 'babel-loader', @@ -127,7 +92,8 @@ const config = { plugins: [ new LicenseWebpackPlugin({ pattern: /^(MIT|ISC|BSD.*)$/, - addUrl: true + addUrl: true, + suppressErrors: true }), new Copy([ ...buildEmbeds.map((embed) => ({ @@ -156,7 +122,7 @@ const config = { alias: { 'plugin-api': path.resolve(__dirname, 'plugin-api/'), plugins: path.resolve(__dirname, 'plugins/'), - pluginsConfig: pluginsConfigPath + pluginsConfig: pluginsPath }, modules: [ path.resolve(__dirname, 'plugins'), @@ -168,6 +134,10 @@ const config = { } }; +//============================================================================== +// Production configuration overrides +//============================================================================== + if (process.env.NODE_ENV === 'production') { config.plugins.push(new CompressionPlugin({ asset: '[path].gz[query]', @@ -178,4 +148,87 @@ if (process.env.NODE_ENV === 'production') { })); } -module.exports = config; +//============================================================================== +// Entries +//============================================================================== + +// Applies the base configuration to the following entries. +const applyConfig = (entries, root = {}) => _.merge({}, config, { + entry: entries.reduce((entry, {name, path}) => { + entry[name] = [ + 'babel-polyfill', + path + ]; + + return entry; + }, {}) +}, root); + +module.exports = [ + + // Coral Embed + applyConfig([ + + // Load in the root embed. + { + name: 'embed', + path: path.join(__dirname, 'client/coral-embed/src/index') + } + + ], { + output: { + library: 'Coral' + } + }), + + // All framework targets/embeds/plugins. + applyConfig([ + + // // Load in all the targets. + ...buildTargets.map((target) => ({ + name: `${target}/bundle`, + path: path.join(__dirname, 'client/', target, '/src/index') + })), + + // Load in all the embeds. + ...buildEmbeds.map((embed) => ({ + name: `embed/${embed}/bundle`, + path: path.join(__dirname, 'client/', `coral-embed-${embed}`, '/src/index') + })), + + // Load in all the plugin entries. + ...targetPlugins.reduce((entries, plugin) => { + + // Introspect the path to find a targets folder. + let folder = path.dirname(plugin.path); + let files = fs.readdirSync(folder); + + // While the folder does not contain the targets folder... + while (!files.includes('targets')) { + + // Try to go up a folder. + folder = path.normalize(path.join(folder, '..')); + + // And as long as we haven't gone too high + if (!(folder.includes(path.join(__dirname, 'node_modules')) || !folder.includes(path.join(__dirname, 'plugins')))) { + throw new Error(`target plugin ${plugin.name} does not have a 'targets' folder`); + } + + files = fs.readdirSync(folder); + } + + // List all targets available in that folder. + folder = path.join(folder, 'targets'); + + let targets = fs.readdirSync(folder); + if (targets.length === 0) { + throw new Error(`target plugin ${plugin.name} has no targets in it's target folder ${folder}`); + } + + return entries.concat(targets.map((target) => ({ + name: `plugin/${plugin.name}/${target}/bundle`, + path: path.join(folder, target, 'index') + }))); + }, []) + ]) +]; diff --git a/yarn.lock b/yarn.lock index 7fcee99c5..6013a3e49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30,9 +30,9 @@ version "0.8.6" resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-0.8.6.tgz#b34fb880493ba835b0c067024ee70130d6f9bb68" -"@types/graphql@^0.9.0": - version "0.9.0" - resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-0.9.0.tgz#fccf859f0d2817687f210737dc3be48a18b1d754" +"@types/graphql@^0.9.0", "@types/graphql@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-0.9.1.tgz#b04ebe84bc997cc60dbea2ed4d0d4342c737f99d" "@types/isomorphic-fetch@0.0.33": version "0.0.33" @@ -53,9 +53,9 @@ "@types/express-serve-static-core" "*" "@types/mime" "*" -"@types/ws@0.0.38": - version "0.0.38" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-0.0.38.tgz#42106fff4b422ca956734e29f0d73a6d893194d3" +"@types/ws@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-3.0.0.tgz#4682a04d385484e73f7d8275cabc8b672d66143c" dependencies: "@types/node" "*" @@ -2704,6 +2704,10 @@ es6-promise@^3.0.2, es6-promise@^3.2.1: version "3.3.1" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" +es6-promise@^4.0.5: + version "4.1.0" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.1.0.tgz#dda03ca8f9f89bc597e689842929de7ba8cebdf0" + es6-set@~0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1" @@ -2941,7 +2945,7 @@ event-stream@~3.3.0: stream-combiner "~0.0.4" through "~2.3.1" -eventemitter3@^2.0.2: +eventemitter3@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-2.0.3.tgz#b5e1079b59fb5e1ba2771c0a993be060a58c99ba" @@ -3631,13 +3635,15 @@ graphql-subscriptions@^0.2.0: dependencies: es6-promise "^3.2.1" -graphql-subscriptions@^0.3.0, graphql-subscriptions@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/graphql-subscriptions/-/graphql-subscriptions-0.3.1.tgz#0cedc2d507420cf26cf414080b079f05402f0303" +graphql-subscriptions@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/graphql-subscriptions/-/graphql-subscriptions-0.4.3.tgz#2aed6ba87551cc747742b793497ff24b22991867" dependencies: - es6-promise "^3.2.1" + "@types/graphql" "^0.9.1" + es6-promise "^4.0.5" + iterall "^1.1.1" -graphql-tag@^1.2.3, graphql-tag@^1.2.4: +graphql-tag@^1.2.3: version "1.3.2" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-1.3.2.tgz#7abb3a8fd9f3415d07163314ed237061c785b759" @@ -4456,6 +4462,10 @@ iterall@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.0.3.tgz#e0b31958f835013c323ff0b10943829ac69aa4b7" +iterall@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.1.1.tgz#f7f0af11e9a04ec6426260f5019d9fcca4d50214" + jju@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/jju/-/jju-1.3.0.tgz#dadd9ef01924bc728b03f2f7979bdbd62f7a2aaa" @@ -5777,10 +5787,6 @@ optionator@^0.8.1, optionator@^0.8.2: type-check "~0.3.2" wordwrap "~1.0.0" -options@>=0.0.5: - version "0.0.6" - resolved "https://registry.yarnpkg.com/options/-/options-0.0.6.tgz#ec22d312806bb53e731773e7cdaefcf1c643128f" - os-browserify@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.2.1.tgz#63fc4ccee5d2d7763d26bbf8601078e6c2e0044f" @@ -7386,7 +7392,7 @@ rx@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782" -safe-buffer@^5.0.1: +safe-buffer@^5.0.1, safe-buffer@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7" @@ -7834,19 +7840,20 @@ stylus@0.54.5, stylus@~0.54.5: sax "0.5.x" source-map "0.1.x" -subscriptions-transport-ws@^0.5.5-alpha.0: - version "0.5.5" - resolved "https://registry.yarnpkg.com/subscriptions-transport-ws/-/subscriptions-transport-ws-0.5.5.tgz#7715ed4306f532a9dd53c02cbd789d2ee8871bb9" +subscriptions-transport-ws@^0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/subscriptions-transport-ws/-/subscriptions-transport-ws-0.7.2.tgz#26917fd4ba23fa37fcd28accb6fe36a040b3bbe9" dependencies: - "@types/ws" "0.0.38" + "@types/ws" "^3.0.0" backo2 "^1.0.2" - eventemitter3 "^2.0.2" - graphql "^0.9.1" - graphql-subscriptions "^0.3.0" - graphql-tag "^1.2.4" + eventemitter3 "^2.0.3" + graphql-subscriptions "^0.4.3" + graphql-tag "^2.0.0" + iterall "^1.1.1" + lodash.assign "^4.2.0" lodash.isobject "^3.0.2" lodash.isstring "^4.0.1" - ws "^1.1.0" + ws "^3.0.0" sugarss@^0.2.0: version "0.2.0" @@ -8190,9 +8197,9 @@ uid-safe@2.1.4, uid-safe@~2.1.4: dependencies: random-bytes "~1.0.0" -ultron@1.0.x: - version "1.0.2" - resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.0.2.tgz#ace116ab557cd197386a4e88f4685378c8b2e4fa" +ultron@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.0.tgz#b07a2e6a541a815fc6a34ccd4533baec307ca864" undefsafe@0.0.3: version "0.0.3" @@ -8510,12 +8517,12 @@ write@^0.2.1: dependencies: mkdirp "^0.5.1" -ws@^1.1.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.4.tgz#57f40d036832e5f5055662a397c4de76ed66bf61" +ws@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-3.0.0.tgz#98ddb00056c8390cb751e7788788497f99103b6c" dependencies: - options ">=0.0.5" - ultron "1.0.x" + safe-buffer "~5.0.1" + ultron "~1.1.0" x-xss-protection@1.0.0: version "1.0.0"