Merge branch '142993479-tags' of github.com:coralproject/talk into 142993479-tags

This commit is contained in:
Wyatt Johnson
2017-05-11 14:42:37 -06:00
27 changed files with 693 additions and 44 deletions
+2
View File
@@ -20,5 +20,7 @@ plugins/*
!plugins/coral-plugin-respect
!plugins/coral-plugin-offtopic
!plugins/coral-plugin-like
!plugins/coral-plugin-mod
!plugins/coral-plugin-love
**/node_modules/*
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright 2016 Mozilla Foundation
Copyright 2017 Mozilla Foundation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -1,16 +1,17 @@
import React, {PropTypes} from 'react';
import timeago from 'timeago.js';
import Linkify from 'react-linkify';
import Highlighter from 'react-highlight-words';
import {Link} from 'react-router';
import Linkify from 'react-linkify';
import styles from './styles.css';
import {Icon} from 'coral-ui';
import FlagBox from './FlagBox';
import styles from './styles.css';
import CommentType from './CommentType';
import Highlighter from 'react-highlight-words';
import Slot from 'coral-framework/components/Slot';
import {getActionSummary} from 'coral-framework/utils';
import ActionButton from 'coral-admin/src/components/ActionButton';
import BanUserButton from 'coral-admin/src/components/BanUserButton';
import {getActionSummary} from 'coral-framework/utils';
const linkify = new Linkify();
@@ -18,11 +19,19 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations.json';
const lang = new I18n(translations);
const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) => {
const Comment = ({
actions = [],
comment,
suspectWords,
bannedWords,
...props
}) => {
const links = linkify.getMatches(comment.body);
const linkText = links ? links.map(link => link.raw) : [];
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions = comment.actions && comment.actions.filter(a => a.__typename === 'FlagAction');
const flagActions =
comment.actions &&
comment.actions.filter(a => a.__typename === 'FlagAction');
let commentType = '';
if (comment.status === 'PREMOD') {
commentType = 'premod';
@@ -33,12 +42,17 @@ const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) =
// since words are checked against word boundaries on the backend,
// this should be the behavior on the front end as well.
// currently the highlighter plugin does not support this out of the box.
const searchWords = [...suspectWords, ...bannedWords].filter(w => {
return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
}).concat(linkText);
const searchWords = [...suspectWords, ...bannedWords]
.filter(w => {
return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
})
.concat(linkText);
return (
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}>
<li
tabIndex={props.index}
className={`mdl-card ${props.selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
@@ -46,53 +60,95 @@ const Comment = ({actions = [], comment, suspectWords, bannedWords, ...props}) =
{comment.user.name}
</span>
<span className={styles.created}>
{timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
{timeago().format(
comment.created_at || Date.now() - props.index * 60 * 1000,
lang.getLocale().replace('-', '_')
)}
</span>
<BanUserButton user={comment.user} onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')} />
<BanUserButton
user={comment.user}
onClick={() =>
props.showBanUserDialog(
comment.user,
comment.id,
comment.status,
comment.status !== 'REJECTED'
)}
/>
<CommentType type={commentType} />
</div>
{comment.user.status === 'banned' ?
<span className={styles.banned}>
<Icon name='error_outline'/>
{lang.t('comment.banned_user')}
</span>
{comment.user.status === 'banned'
? <span className={styles.banned}>
<Icon name="error_outline" />
{lang.t('comment.banned_user')}
</span>
: null}
<Slot fill="adminCommentInfoBar" comment={comment} />
</div>
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!props.currentAsset && (
<Link to={`/admin/moderate/${comment.asset.id}`}>Moderate </Link>
)}
{!props.currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>Moderate </Link>}
</div>
<div className={styles.itemBody}>
<p className={styles.body}>
<Highlighter
searchWords={searchWords}
textToHighlight={comment.body} /> <a className={styles.external} href={`${comment.asset.url}#${comment.id}`} target="_blank"><Icon name='open_in_new' /> {lang.t('comment.view_context')}</a>
textToHighlight={comment.body}
/>
{' '}
<a
className={styles.external}
href={`${comment.asset.url}#${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {lang.t('comment.view_context')}
</a>
</p>
<Slot fill="adminCommentContent" comment={comment} />
<div className={styles.sideActions}>
{links ? <span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
{links
? <span className={styles.hasLinks}>
<Icon name="error_outline" /> Contains Link
</span>
: null}
<div className={`actions ${styles.actions}`}>
{actions.map((action, i) => {
const active = (action === 'REJECT' && comment.status === 'REJECTED') ||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return <ActionButton key={i}
type={action}
user={comment.user}
status={comment.status}
active={active}
acceptComment={() => comment.status === 'ACCEPTED' ? null : props.acceptComment({commentId: comment.id})}
rejectComment={() => comment.status === 'REJECTED' ? null : props.rejectComment({commentId: comment.id})} />;
const active =
(action === 'REJECT' && comment.status === 'REJECTED') ||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return (
<ActionButton
key={i}
type={action}
user={comment.user}
status={comment.status}
active={active}
acceptComment={() =>
(comment.status === 'ACCEPTED'
? null
: props.acceptComment({commentId: comment.id}))}
rejectComment={() =>
(comment.status === 'REJECTED'
? null
: props.rejectComment({commentId: comment.id}))}
/>
);
})}
</div>
<Slot fill="adminSideActions" comment={comment} />
</div>
</div>
</div>
{
flagActions && flagActions.length
? <FlagBox actions={flagActions} actionSummaries={flagActionSummaries} />
: null
}
<div>
<Slot fill="adminCommentDetailArea" comment={comment} />
</div>
{flagActions && flagActions.length
? <FlagBox
actions={flagActions}
actionSummaries={flagActionSummaries}
/>
: null}
</li>
);
};
+3
View File
@@ -0,0 +1,3 @@
export function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}
+1 -1
View File
@@ -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) {
@@ -0,0 +1,4 @@
export function capitalize(str) {
const newString = new String(str);
return newString.charAt(0).toUpperCase() + newString.slice(1);
}
+7
View File
@@ -0,0 +1,7 @@
import withFragments from './withFragments';
import withReaction from './withReaction';
export default {
withFragments,
withReaction
};
+1 -5
View File
@@ -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() {
+243
View File
@@ -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 <WrappedComponent {...this.props} {...withReactionProps} />;
}
}
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);
};
+2
View File
@@ -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
};
+1
View File
@@ -0,0 +1 @@
export {withReaction} from '../coral-framework/hocs';
+14
View File
@@ -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"
]
}
@@ -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"] }]
}
}
@@ -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 (
<button
className={`${styles.button} ${alreadyReacted() ? styles.loved : ''}`}
onClick={this.handleClick}
>
<span>{lang.t(alreadyReacted() ? 'loved' : 'love')}</span>
<Icon name="favorite" />
<span>{count > 0 && count}</span>
</button>
);
}
}
export default withReaction('love')(LoveButton);
@@ -0,0 +1,7 @@
import LoveButton from './LoveButton';
export default {
slots: {
commentReactions: [LoveButton]
}
};
@@ -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;
}
}
}
@@ -0,0 +1,10 @@
{
"en": {
"love": "Love",
"loved": "Loved"
},
"es": {
"love": "Amo",
"loved": "Amé"
}
}
+37
View File
@@ -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';
}
}
}
}
}
};
@@ -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
}
+14
View File
@@ -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"
]
}
@@ -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"] }]
}
}
@@ -0,0 +1,8 @@
import React from 'react';
import styles from './styles.css'
export default (props) => (
<div className={styles.box}>
Comment Status: {props.comment.status}
</div>
)
@@ -0,0 +1,32 @@
import React from 'react';
import Box from './Box';
import {Button} from 'coral-ui'
import styles from './styles.css';
export default class Footer extends React.Component {
constructor() {
super();
this.state = {
show: false
};
}
handleClick = () => {
this.setState(state => ({
show: !state.show
}))
}
render() {
const {show} = this.state;
return (
<div className={styles.container}>
<Button cStyle="darkGrey" onClick={this.handleClick}>
Show Comment Status
</Button>
{show ? <Box comment={this.props.comment} /> : null}
</div>
);
}
}
@@ -0,0 +1,8 @@
.container {
padding: 0 14px 10px;
}
.box {
font-size: 12px;
padding: 10px 14px;
}
+7
View File
@@ -0,0 +1,7 @@
import Container from './components/Container';
export default {
slots: {
adminCommentDetailArea: [Container],
}
};
+4
View File
@@ -0,0 +1,4 @@
const {readFileSync} = require('fs');
const path = require('path');
module.exports = {};
+1 -1
View File
@@ -205,7 +205,7 @@ module.exports = class CommentsService {
* @param {String} action the new action to the comment
* @return {Promise}
*/
static addAction(item_id, user_id, action_type, metadata) {
static addAction(item_id, user_id, action_type, metadata = {}) {
return ActionsService.insertUserAction({
item_id,
item_type: 'COMMENTS',