diff --git a/.gitignore b/.gitignore
index 9f04eb4b8..f0c3b2a4b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,5 +21,6 @@ plugins/*
!plugins/coral-plugin-offtopic
!plugins/coral-plugin-like
!plugins/coral-plugin-mod
+!plugins/coral-plugin-love
**/node_modules/*
diff --git a/client/coral-framework/helpers/hoc.js b/client/coral-framework/helpers/hoc.js
new file mode 100644
index 000000000..bb034b179
--- /dev/null
+++ b/client/coral-framework/helpers/hoc.js
@@ -0,0 +1,3 @@
+export function getDisplayName(WrappedComponent) {
+ return WrappedComponent.displayName || WrappedComponent.name || 'Component';
+}
diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js
index 20aee7abb..d701b933f 100644
--- a/client/coral-framework/helpers/plugins.js
+++ b/client/coral-framework/helpers/plugins.js
@@ -21,7 +21,7 @@ export function getSlotElements(slot, props = {}) {
.filter(o => o.module.slots[slot])
.map(o => o.module.slots[slot]));
return components
- .map((component, i) => React.createElement(component, {...props, key: i}));
+ .map((component, i) => React.createElement(component, {key: i, ...props}));
}
function getComponentFragments(components) {
diff --git a/client/coral-framework/helpers/strings.js b/client/coral-framework/helpers/strings.js
new file mode 100644
index 000000000..0a267da59
--- /dev/null
+++ b/client/coral-framework/helpers/strings.js
@@ -0,0 +1,4 @@
+export function capitalize(str) {
+ const newString = new String(str);
+ return newString.charAt(0).toUpperCase() + newString.slice(1);
+}
diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js
new file mode 100644
index 000000000..831c356d2
--- /dev/null
+++ b/client/coral-framework/hocs/index.js
@@ -0,0 +1,7 @@
+import withFragments from './withFragments';
+import withReaction from './withReaction';
+
+export default {
+ withFragments,
+ withReaction
+};
diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js
index a2686d94b..a5060f27a 100644
--- a/client/coral-framework/hocs/withFragments.js
+++ b/client/coral-framework/hocs/withFragments.js
@@ -1,11 +1,7 @@
import React from 'react';
+import {getDisplayName} from '../helpers/hoc';
// TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38.
-
-function getDisplayName(WrappedComponent) {
- return WrappedComponent.displayName || WrappedComponent.name || 'Component';
-}
-
export default fragments => WrappedComponent => {
class WithFragments extends React.Component {
render() {
diff --git a/client/coral-framework/hocs/withReaction.js b/client/coral-framework/hocs/withReaction.js
new file mode 100644
index 000000000..c383dbed8
--- /dev/null
+++ b/client/coral-framework/hocs/withReaction.js
@@ -0,0 +1,243 @@
+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 mapDispatchToProps = dispatch =>
+ bindActionCreators({showSignInDialog}, dispatch);
+
+ const enhance = compose(
+ withFragments({
+ root: gql`
+ fragment ${capitalize(reaction)}Button_root on RootQuery {
+ me {
+ status
+ }
+ }
+ `,
+ comment: gql`
+ fragment ${capitalize(reaction)}Button_comment on Comment {
+ action_summaries {
+ ... on ${capitalize(reaction)}ActionSummary {
+ count
+ current_user {
+ id
+ }
+ }
+ }
+ }`
+ }),
+ connect(null, mapDispatchToProps),
+ withDeleteReaction,
+ withPostReaction
+ );
+
+ WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`;
+
+ return enhance(WithReactions);
+};
diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js
index b85b69bcd..46ee704f4 100644
--- a/client/coral-framework/index.js
+++ b/client/coral-framework/index.js
@@ -1,6 +1,7 @@
import pym from './services/PymConnection';
import I18n from './modules/i18n/i18n';
import actions from './actions';
+import hocs from './hocs';
// TODO (bc): Deprecate old actions. Spreading actions is now needed.
@@ -8,5 +9,6 @@ export default {
pym,
I18n,
actions,
+ ...hocs,
...actions
};
diff --git a/client/coral-plugin-api/index.js b/client/coral-plugin-api/index.js
new file mode 100644
index 000000000..f0f5ad9cc
--- /dev/null
+++ b/client/coral-plugin-api/index.js
@@ -0,0 +1 @@
+export {withReaction} from '../coral-framework/hocs';
diff --git a/plugins/coral-plugin-love/client/.babelrc b/plugins/coral-plugin-love/client/.babelrc
new file mode 100644
index 000000000..60be246eb
--- /dev/null
+++ b/plugins/coral-plugin-love/client/.babelrc
@@ -0,0 +1,14 @@
+{
+ "presets": [
+ "es2015"
+ ],
+ "plugins": [
+ "add-module-exports",
+ "transform-class-properties",
+ "transform-decorators-legacy",
+ "transform-object-assign",
+ "transform-object-rest-spread",
+ "transform-async-to-generator",
+ "transform-react-jsx"
+ ]
+}
\ No newline at end of file
diff --git a/plugins/coral-plugin-love/client/.eslintrc.json b/plugins/coral-plugin-love/client/.eslintrc.json
new file mode 100644
index 000000000..9fe56bd14
--- /dev/null
+++ b/plugins/coral-plugin-love/client/.eslintrc.json
@@ -0,0 +1,23 @@
+{
+ "env": {
+ "browser": true,
+ "es6": true,
+ "mocha": true
+ },
+ "parserOptions": {
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "experimentalObjectRestSpread": true,
+ "jsx": true
+ }
+ },
+ "parser": "babel-eslint",
+ "plugins": [
+ "react"
+ ],
+ "rules": {
+ "react/jsx-uses-react": "error",
+ "react/jsx-uses-vars": "error",
+ "no-console": ["warn", { "allow": ["warn", "error"] }]
+ }
+}
diff --git a/plugins/coral-plugin-love/client/LoveButton.js b/plugins/coral-plugin-love/client/LoveButton.js
new file mode 100644
index 000000000..4364c4818
--- /dev/null
+++ b/plugins/coral-plugin-love/client/LoveButton.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import {Icon} from 'coral-ui';
+import styles from './styles.css';
+import {I18n} from 'coral-framework';
+import translations from './translations.json';
+import {withReaction} from 'coral-plugin-api';
+const lang = new I18n(translations);
+
+class LoveButton extends React.Component {
+ handleClick = () => {
+ const {
+ postReaction,
+ deleteReaction,
+ showSignInDialog,
+ alreadyReacted
+ } = this.props;
+ const {root: {me}, comment} = this.props;
+
+ // 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 (alreadyReacted()) {
+ deleteReaction();
+ } else {
+ postReaction();
+ }
+ };
+
+ render() {
+ const {count, alreadyReacted} = this.props;
+ return (
+
+ );
+ }
+}
+
+export default withReaction('love')(LoveButton);
diff --git a/plugins/coral-plugin-love/client/index.js b/plugins/coral-plugin-love/client/index.js
new file mode 100644
index 000000000..f048963b4
--- /dev/null
+++ b/plugins/coral-plugin-love/client/index.js
@@ -0,0 +1,7 @@
+import LoveButton from './LoveButton';
+
+export default {
+ slots: {
+ commentReactions: [LoveButton]
+ }
+};
diff --git a/plugins/coral-plugin-love/client/styles.css b/plugins/coral-plugin-love/client/styles.css
new file mode 100644
index 000000000..d48e7e28c
--- /dev/null
+++ b/plugins/coral-plugin-love/client/styles.css
@@ -0,0 +1,26 @@
+.respect {
+ display: inline-block;
+}
+
+.button {
+ 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: #e52839;
+ cursor: pointer;
+ }
+ }
+}
diff --git a/plugins/coral-plugin-love/client/translations.json b/plugins/coral-plugin-love/client/translations.json
new file mode 100644
index 000000000..a015efa77
--- /dev/null
+++ b/plugins/coral-plugin-love/client/translations.json
@@ -0,0 +1,10 @@
+{
+ "en": {
+ "love": "Love",
+ "loved": "Loved"
+ },
+ "es": {
+ "love": "Amo",
+ "loved": "Amé"
+ }
+}
diff --git a/plugins/coral-plugin-love/index.js b/plugins/coral-plugin-love/index.js
new file mode 100644
index 000000000..48b4d16c0
--- /dev/null
+++ b/plugins/coral-plugin-love/index.js
@@ -0,0 +1,37 @@
+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';
+ }
+ }
+ }
+ }
+ }
+};
+
diff --git a/plugins/coral-plugin-love/server/typeDefs.graphql b/plugins/coral-plugin-love/server/typeDefs.graphql
new file mode 100644
index 000000000..e22b91a2d
--- /dev/null
+++ b/plugins/coral-plugin-love/server/typeDefs.graphql
@@ -0,0 +1,70 @@
+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
+}