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/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 ( +
+ +
+ ); + } +} + +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 ( -
- -
- ); - } -} - -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 ( - +
+ +
); } } 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 ( +
+ +
+ ); + } +} + +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}) => ( -