Fix merge conflict

This commit is contained in:
Kim Gardner
2018-03-07 14:08:19 -05:00
142 changed files with 2527 additions and 668 deletions
+21 -6
View File
@@ -8,6 +8,7 @@ job_defaults: &job_defaults
integration_environment: &integration_environment
NODE_ENV: test
CIRCLE_TEST_REPORTS: /tmp/circleci-test-results
E2E_MAX_RETRIES: 3
# integration_job runs the integration tests and saves the test results.
integration_job: &integration_job
@@ -29,6 +30,7 @@ integration_job: &integration_job
name: Run the integration tests
command: bash .circleci/e2e.sh
- store_test_results:
when: always
path: /tmp/circleci-test-results
version: 2
@@ -104,13 +106,19 @@ jobs:
name: Setup the test results directory
command: mkdir -p /tmp/circleci-test-results
- run:
name: Run the unit tests
command: yarn test
name: Run the client unit tests
command: yarn test:client --ci
environment:
MOCHA_FILE: /tmp/circleci-test-results/junit/test-results.xml
JEST_JUNIT_OUTPUT: /tmp/circleci-test-results/jest/test-results.xml
JEST_REPORTER: jest-junit
- run:
name: Run the server unit tests
command: yarn test:server
environment:
MOCHA_FILE: /tmp/circleci-test-results/mocha/test-results.xml
MOCHA_REPORTER: mocha-junit-reporter
NODE_ENV: test
- store_test_results:
when: always
path: /tmp/circleci-test-results
# test_integration_chrome_local will run the integration tests locally with
@@ -164,6 +172,8 @@ jobs:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: ie
# TODO: remove when more reliable
E2E_MAX_RETRIES: 1
# test_integration_safari will run the integration tests with safari in
# browserstack.
@@ -173,6 +183,8 @@ jobs:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: safari
# TODO: remove when more reliable
E2E_MAX_RETRIES: 1
# deploy will deploy the application as a docker image.
deploy:
@@ -182,7 +194,7 @@ jobs:
- setup_remote_docker
- run:
name: Deploy the code
command: bash ./scripts/docker.sh
command: bash ./scripts/docker.sh deploy
# filter_deploy will add the filters for a deploy job in a workflow to make it
# only execute on a deploy related job.
@@ -274,4 +286,7 @@ workflows:
- test_unit
- test_integration_chrome
- test_integration_firefox
- test_integration_edge
- test_integration_edge
# TODO: uncomment when more reliable
# - test_integration_ie
# - test_integration_safari
+1 -1
View File
@@ -2,4 +2,4 @@
dist
node_modules
public
**/*.min.js
**/*.min.js
+32 -3
View File
@@ -1,5 +1,34 @@
### Expected behavior
<!--
### Actual behavior
Thank you for filing an issue on Coral Talk!
### Steps to reproduce behavior
Please fill out the questions below so we can take action on your issue as soon as we can.
If you're filing a feature request, you do not need to follow the outline below. Instead please include "Feature Idea" in your issue title and explain a specific example in which that feature would be useful.
-->
#### Do you want to request a **feature** or report a **bug**?
#### Intended outcome:
<!--
What you were trying to accomplish when the bug occurred?
-->
#### Actual outcome:
<!--
What happened instead?
Please provide as much detail as possible, including a screenshot or copy-paste of any related error messages, logs, or other output that might be related. Places to look for information include your browser console, server console, and network logs. The more information you can give the better.
-->
#### How to reproduce the issue:
<!--
Instructions for how the issue can be reproduced by someone from our team or by a contributor. Be as specific as possible, and only mention what is necessary to reproduce the bug. If possible, try to isolate the exact circumstances in which the bug occurs and avoid speculation over what the cause might be.
-->
#### Version and environment
<!--
List what version of Talk you're using, as well as any other relevant environment information, such as operating system or browser
-->
+4
View File
@@ -44,7 +44,9 @@ plugins/*
!plugins/talk-plugin-mod
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-notifications
!plugins/talk-plugin-notifications-category-featured
!plugins/talk-plugin-notifications-category-reply
!plugins/talk-plugin-notifications-category-staff
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
!plugins/talk-plugin-profile-settings
@@ -60,6 +62,8 @@ plugins/*
!plugins/talk-plugin-subscriber
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-viewing-options
!plugins/talk-plugin-rich-text
!plugins/talk-plugin-rich-text-pell
**/node_modules/*
yarn-error.log
+4
View File
@@ -15,6 +15,10 @@ COPY . /usr/src/app
# Ensure the runtime of the container is in production mode.
ENV NODE_ENV production
# Store the current git revision.
ARG REVISION_HASH
ENV REVISION_HASH=${REVISION_HASH}
# Install app dependencies and build static assets.
RUN yarn global add node-gyp && \
yarn install --frozen-lockfile && \
+1
View File
@@ -18,5 +18,6 @@ ONBUILD COPY . /usr/src/app
# clear out the development dependencies again. After this we of course need to
# clear out the yarn cache, this saves quite a lot of size.
ONBUILD RUN cli plugins reconcile && \
yarn && \
yarn build && \
yarn cache clean
+1 -1
View File
@@ -1,4 +1,4 @@
# Talk &middot; [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) &middot; [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8/badge)](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) &middot; [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests)
# Talk &middot; [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) &middot; [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946/badge)](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946) &middot; [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests)
Online comments are broken. Our open-source commenting platform, Talk, rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html).
@@ -78,6 +78,12 @@
font-weight: 300;
font-size: 16px;
max-width: 360px;
blockquote {
font-size: inherit;
line-height: inherit;
letter-spacing: inherit;
}
}
.sideActions {
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import Slot from 'coral-framework/components/Slot';
import { Link } from 'react-router';
import { Icon } from 'coral-ui';
import CommentDetails from './CommentDetails';
import styles from './UserDetailComment.css';
@@ -33,9 +33,17 @@ class UserDetailComment extends React.Component {
toggleSelect,
className,
data,
root: { settings: { wordlist: { banned, suspect } } },
root: { settings },
} = this.props;
const queryData = { root, comment };
const formatterSettings = {
suspectWords: settings.wordlist.suspect,
bannedWords: settings.wordlist.banned,
body: comment.body,
};
return (
<li
tabIndex={0}
@@ -78,11 +86,17 @@ class UserDetailComment extends React.Component {
<CommentAnimatedEdit body={comment.body}>
<div className={styles.bodyContainer}>
<div className={styles.body}>
<CommentFormatter
suspectWords={suspect}
bannedWords={banned}
body={comment.body}
className="talk-admin-user-detail-comment"
<Slot
fill="userDetailCommentContent"
data={data}
className={cn(
styles.commentContent,
'talk-admin-user-detail-comment'
)}
queryData={queryData}
slotSize={1}
defaultComponent={CommentFormatter}
{...formatterSettings}
/>
<a
className={styles.external}
@@ -4,6 +4,9 @@ import withFragments from 'coral-framework/hocs/withFragments';
import { getDefinitionName } from 'coral-framework/utils';
import CommentLabels from './CommentLabels';
import CommentDetails from './CommentDetails';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
const slots = ['userDetailCommentContent'];
export default withFragments({
root: gql`
@@ -14,6 +17,7 @@ export default withFragments({
suspect
}
}
${getSlotFragmentSpreads(slots, 'root')}
...${getDefinitionName(CommentLabels.fragments.root)}
...${getDefinitionName(CommentDetails.fragments.root)}
}
@@ -38,6 +42,7 @@ export default withFragments({
status_history {
type
}
${getSlotFragmentSpreads(slots, 'comment')}
...${getDefinitionName(CommentLabels.fragments.comment)}
...${getDefinitionName(CommentDetails.fragments.comment)}
}
@@ -72,9 +72,9 @@
flex: 1;
color: black;
max-width: 500px;
word-wrap: break-word;
font-weight: 300;
font-size: 16px;
word-break: break-all;
}
.created {
@@ -133,7 +133,6 @@
color: #063b9a;
cursor: pointer;
font-weight: normal;
margin-left: 10px;
white-space: nowrap;
&:hover {
@@ -184,3 +183,17 @@
}
}
}
.commentContent {
display: flex;
blockquote {
font-size: inherit;
line-height: inherit;
letter-spacing: inherit;
}
}
.commentContentFooter {
flex: 1;
}
@@ -64,6 +64,12 @@ class Comment extends React.Component {
const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
const queryData = { root, comment, asset: comment.asset };
const formatterSettings = {
suspectWords: settings.wordlist.suspect,
bannedWords: settings.wordlist.banned,
body: comment.body,
};
return (
<li
tabIndex={0}
@@ -126,26 +132,27 @@ class Comment extends React.Component {
<CommentAnimatedEdit body={comment.body}>
<div className={styles.itemBody}>
<div className={styles.body}>
<CommentFormatter
suspectWords={settings.wordlist.suspect}
bannedWords={settings.wordlist.banned}
className="talk-admin-comment"
body={comment.body}
<Slot
fill="adminCommentContent"
data={data}
className={cn(styles.commentContent, 'talk-admin-comment')}
clearHeightCache={clearHeightCache}
queryData={queryData}
slotSize={1}
defaultComponent={CommentFormatter}
{...formatterSettings}
/>
<a
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
</a>
<div className={styles.commentContentFooter}>
<a
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
</a>
</div>
</div>
<Slot
fill="adminCommentContent"
data={data}
clearHeightCache={clearHeightCache}
queryData={queryData}
/>
<div className={styles.sideActions}>
<IfHasLink text={comment.body}>
<span className={styles.hasLinks}>
+33 -8
View File
@@ -141,7 +141,7 @@ export function findCommentInAsset(asset, callbackOrId) {
callback = node => node.id === callbackOrId;
}
if (asset.comment) {
return findComment([getTopLevelParent(asset.comment)], callback);
return findComment([reverseCommentParentTree(asset.comment)], callback);
}
if (!asset.comments) {
return false;
@@ -187,11 +187,12 @@ export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) {
);
}
/**
* attachCommentToParent recurses through the comment tree starting at `topLevelComment`
* to find the parent of `comment` and attach it to the replies.
*/
export function attachCommentToParent(topLevelComment, comment) {
function attachComment(topLevelComment, comment) {
if (!topLevelComment.replies) {
topLevelComment = update(topLevelComment, {
replies: { $set: { nodes: [] } },
});
}
if (topLevelComment.id === comment.parent.id) {
return update(topLevelComment, {
replies: {
@@ -204,16 +205,40 @@ export function attachCommentToParent(topLevelComment, comment) {
},
});
}
if (!topLevelComment.replies.nodes.length) {
return topLevelComment;
}
return update(topLevelComment, {
replies: {
nodes: {
$apply: nodes =>
nodes.map(node => attachCommentToParent(node, comment)),
$apply: nodes => nodes.map(node => attachComment(node, comment)),
},
},
});
}
/**
* attachCommentToParent recurses through the comment tree starting at `topLevelComment`
* to find the ancestor of `comment` and attach it to the replies.
*/
export function attachCommentToParent(topLevelComment, comment) {
let result = topLevelComment;
if (comment.parent.parent) {
result = attachCommentToParent(result, comment.parent);
}
return attachComment(result, comment);
}
/**
* reverseCommentParentTree reverses a comment parent relationship tree
* like `comment -> parent -> parent` into `parent -> parent -> comment -> replies`.
*/
export function reverseCommentParentTree(comment) {
return comment.parent
? attachCommentToParent(getTopLevelParent(comment), comment)
: comment;
}
/**
* Nest a string in itself repeatly until `level` has been reached.
*
@@ -22,6 +22,10 @@
.commentLevel0 {
padding-left: 0px;
&.highlightedComment {
margin-top: 8px;
}
}
.commentLevel1 {
@@ -41,8 +45,8 @@
}
.highlightedComment {
padding-left: 15px;
border-left: 3px solid rgb(35,118,216);
padding: 1px 15px 8px 15px;
background-color: #E3F2FD;
}
.bylineSecondary {
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import TagLabel from './TagLabel';
import CommentTimestamp from 'coral-framework/components/CommentTimestamp';
import ReplyButton from './ReplyButton';
import ReplyBox from './ReplyBox';
import ReplyBox from '../containers/ReplyBox';
import FlagComment from './FlagComment';
import { can } from 'coral-framework/services/perms';
import { TransitionGroup } from 'react-transition-group';
@@ -13,6 +13,7 @@ import styles from './Comment.css';
import { THREADING_LEVEL } from '../../../constants/stream';
import merge from 'lodash/merge';
import mapValues from 'lodash/mapValues';
import get from 'lodash/get';
import LoadMore from './LoadMore';
import { getEditableUntilDate } from './util';
@@ -21,7 +22,7 @@ import CommentContent from 'coral-framework/components/CommentContent';
import Slot from 'coral-framework/components/Slot';
import CommentTombstone from './CommentTombstone';
import InactiveCommentLabel from './InactiveCommentLabel';
import { EditableCommentContent } from './EditableCommentContent';
import EditableCommentContent from '../containers/EditableCommentContent';
import {
getActionSummary,
iPerformedThisAction,
@@ -169,7 +170,7 @@ export default class Comment extends React.Component {
postFlag: PropTypes.func.isRequired,
deleteAction: PropTypes.func.isRequired,
parentId: PropTypes.string,
highlighted: PropTypes.string,
highlighted: PropTypes.object,
notify: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
depth: PropTypes.number.isRequired,
@@ -186,28 +187,7 @@ export default class Comment extends React.Component {
postDontAgree: PropTypes.func,
animateEnter: PropTypes.bool,
commentClassNames: PropTypes.array,
comment: PropTypes.shape({
depth: PropTypes.number,
action_summaries: PropTypes.array.isRequired,
body: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
})
),
replies: PropTypes.object,
user: PropTypes.shape({
id: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
}).isRequired,
editing: PropTypes.shape({
edited: PropTypes.bool,
// ISO8601
editableUntil: PropTypes.string,
}),
}).isRequired,
comment: PropTypes.object.isRequired,
setCommentStatus: PropTypes.func.isRequired,
// edit a comment, passed (id, asset_id, { body })
@@ -301,6 +281,14 @@ export default class Comment extends React.Component {
this.props.setActiveReplyBox('');
};
undoStatus = () =>
this.props.setCommentStatus({
commentId: this.props.comment.id,
status: this.props.comment.status_history[
this.props.comment.status_history.length - 2
].type,
});
// getVisibileReplies returns a list containing comments
// which were authored by current user or comes before the `idCursor`.
getVisibileReplies() {
@@ -329,6 +317,27 @@ export default class Comment extends React.Component {
return view;
}
/**
* getConditionalClassNames
* conditionalClassNames adds classNames based on condition
* classnames is an array of objects with key as classnames and value as conditions
* i.e:
* {
* 'myClassName': { tags: [STAFF]}
* }
*
* This will add myClassName to comments tagged with STAFF TAG.
**/
getConditionalClassNames() {
const { commentClassNames = [] } = this.props;
return mapValues(merge({}, ...commentClassNames), condition => {
if (condition.tags) {
return condition.tags.some(tag => hasTag(this.props.comment.tags, tag));
}
return false;
});
}
componentDidMount() {
this._isMounted = true;
if (this.editWindowExpiryTimeout) {
@@ -343,25 +352,54 @@ export default class Comment extends React.Component {
}, Math.max(msLeftToEdit, 0));
}
}
componentWillUnmount() {
if (this.editWindowExpiryTimeout) {
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
}
this._isMounted = false;
}
render() {
renderReplyBox() {
const {
asset,
data,
depth,
comment,
parentId,
postComment,
currentUser,
setActiveReplyBox,
maxCharCount,
notify,
charCountEnable,
root,
} = this.props;
return (
<ReplyBox
root={root}
comment={comment}
commentPostedHandler={this.commentPostedHandler}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
setActiveReplyBox={setActiveReplyBox}
parentId={depth < THREADING_LEVEL ? comment.id : parentId}
notify={notify}
postComment={postComment}
currentUser={currentUser}
assetId={asset.id}
/>
);
}
renderReplies(view) {
const {
asset,
depth,
comment,
postFlag,
parentId,
highlighted,
postComment,
currentUser,
postDontAgree,
setActiveReplyBox,
activeReplyBox,
loadMore,
@@ -372,39 +410,47 @@ export default class Comment extends React.Component {
charCountEnable,
showSignInDialog,
liveUpdates,
animateEnter,
emit,
commentClassNames = [],
} = this.props;
return (
<TransitionGroup key="transitionGroup">
{view.map(reply => {
return (
<CommentContainer
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
notify={notify}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
loadMore={loadMore}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
showSignInDialog={showSignInDialog}
liveUpdates={liveUpdates}
reactKey={reply.id}
key={reply.id}
comment={reply}
emit={emit}
/>
);
})}
</TransitionGroup>
);
}
if (!highlighted && this.commentIsRejected(comment)) {
return (
<CommentTombstone
action="reject"
onUndo={() => {
this.props.setCommentStatus({
commentId: comment.id,
status:
comment.status_history[comment.status_history.length - 2].type,
});
}}
/>
);
}
if (this.commentIsIgnored(comment)) {
return <CommentTombstone action="ignore" />;
}
const view = this.getVisibileReplies();
// Inactive comments can be viewed by moderators and admins (e.g. using permalinks).
const isActive = isCommentActive(comment.status);
renderLoadMoreReplies(view) {
const { comment } = this.props;
const { loadingState } = this.state;
const isPending = comment.id.indexOf('pending') >= 0;
const isHighlighted = highlighted === comment.id;
const hasMoreComments =
comment.replies &&
(comment.replies.hasNextPage ||
@@ -412,6 +458,57 @@ export default class Comment extends React.Component {
const moreRepliesCount = this.hasIgnoredReplies()
? -1
: comment.replyCount - view.length;
return (
<div className="talk-load-more-replies" key="loadMoreReplies">
<LoadMore
topLevel={false}
replyCount={moreRepliesCount}
moreComments={hasMoreComments}
loadMore={this.loadNewReplies}
loadingState={loadingState}
/>
</div>
);
}
renderRepliesContainer() {
const { highlighted, comment } = this.props;
// Only render highlighted reply when we are the parent of it.
if (get(highlighted, 'parent.id') === comment.id) {
return this.renderReplies([highlighted]);
}
// Otherwise render replies in current view and a load more button if needed.
const view = this.getVisibileReplies();
return [this.renderReplies(view), this.renderLoadMoreReplies(view)];
}
renderComment() {
const {
asset,
data,
root,
depth,
comment,
postFlag,
parentId,
highlighted,
currentUser,
postDontAgree,
deleteAction,
disableReply,
maxCharCount,
notify,
charCountEnable,
showSignInDialog,
} = this.props;
// Inactive comments can be viewed by moderators and admins (e.g. using permalinks).
const isActive = isCommentActive(comment.status);
const isPending = comment.id.indexOf('pending') >= 0;
const isHighlighted = highlighted && highlighted.id === comment.id;
const flagSummary = getActionSummary('FlagActionSummary', comment);
const dontAgreeSummary = getActionSummary(
'DontAgreeActionSummary',
@@ -424,38 +521,6 @@ export default class Comment extends React.Component {
myFlag = dontAgreeSummary.find(s => s.current_user);
}
/**
* conditionClassNames
* adds classNames based on condition
* classnames is an array of objects with key as classnames and value as conditions
* i.e:
* {
* 'myClassName': { tags: [STAFF]}
* }
*
* This will add myClassName to comments tagged with STAFF TAG.
* **/
const conditionalClassNames = mapValues(
merge({}, ...commentClassNames),
condition => {
if (condition.tags) {
return condition.tags.some(tag => hasTag(comment.tags, tag));
}
return false;
}
);
const rootClassName = cn(
'talk-stream-comment-wrapper',
`talk-stream-comment-wrapper-level-${depth}`,
styles.root,
styles[`rootLevel${depth}`],
{
...conditionalClassNames,
[styles.enter]: animateEnter,
}
);
const commentClassName = cn(
'talk-stream-comment',
`talk-stream-comment-level-${depth}`,
@@ -482,241 +547,221 @@ export default class Comment extends React.Component {
};
return (
<div className={rootClassName} id={`c_${comment.id}`}>
<div className={commentClassName}>
<Slot
className={`${styles.commentAvatar} talk-stream-comment-avatar`}
fill="commentAvatar"
{...slotProps}
queryData={queryData}
inline
/>
<div className={commentClassName}>
<Slot
className={cn(styles.commentAvatar, 'talk-stream-comment-avatar')}
fill="commentAvatar"
{...slotProps}
queryData={queryData}
inline
/>
<div
className={cn(
styles.commentContainer,
'talk-stream-comment-container'
)}
>
<div className={cn(styles.header, 'talk-stream-comment-header')}>
<div
className={cn(
styles.headerContainer,
'talk-stream-comment-header-container'
)}
>
<Slot
className={cn(styles.username, 'talk-stream-comment-user-name')}
fill="commentAuthorName"
defaultComponent={CommentAuthorName}
queryData={queryData}
{...slotProps}
/>
<div
className={cn(
styles.commentContainer,
'talk-stream-comment-container'
)}
>
<div className={cn(styles.header, 'talk-stream-comment-header')}>
<div
className={cn(
styles.headerContainer,
'talk-stream-comment-header-container'
styles.tagsContainer,
'talk-stream-comment-header-tags-container'
)}
>
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
<Slot
className={cn(
styles.commentAuthorTagsSlot,
'talk-stream-comment-author-tags'
)}
fill="commentAuthorTags"
queryData={queryData}
{...slotProps}
inline
/>
</div>
<span
className={cn(
styles.bylineSecondary,
'talk-stream-comment-user-byline'
)}
>
<Slot
className={cn(
styles.username,
'talk-stream-comment-user-name'
)}
fill="commentAuthorName"
defaultComponent={CommentAuthorName}
fill="commentTimestamp"
defaultComponent={CommentTimestamp}
className={'talk-stream-comment-published-date'}
created_at={comment.created_at}
queryData={queryData}
{...slotProps}
/>
<div
className={cn(
styles.tagsContainer,
'talk-stream-comment-header-tags-container'
)}
>
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
<Slot
className={cn(
styles.commentAuthorTagsSlot,
'talk-stream-comment-author-tags'
)}
fill="commentAuthorTags"
queryData={queryData}
{...slotProps}
inline
/>
</div>
<span
className={`${
styles.bylineSecondary
} talk-stream-comment-user-byline`}
>
<Slot
fill="commentTimestamp"
defaultComponent={CommentTimestamp}
className={'talk-stream-comment-published-date'}
created_at={comment.created_at}
queryData={queryData}
{...slotProps}
/>
{comment.editing && comment.editing.edited ? (
<span>
&nbsp;<span className={styles.editedMarker}>
({t('comment.edited')})
</span>
{comment.editing && comment.editing.edited ? (
<span>
&nbsp;<span className={styles.editedMarker}>
({t('comment.edited')})
</span>
) : null}
</span>
</div>
<Slot
className={styles.commentInfoBar}
fill="commentInfoBar"
{...slotProps}
queryData={queryData}
/>
{isActive &&
(currentUser && comment.user.id === currentUser.id) && (
/* User can edit/delete their own comment for a short window after posting */
<span className={cn(styles.topRight)}>
{this.state.isEditable && (
<a
className={cn(styles.link, {
[styles.active]: this.state.isEditing,
})}
onClick={this.onClickEdit}
>
Edit
</a>
)}
</span>
)}
{!isActive && <InactiveCommentLabel status={comment.status} />}
) : null}
</span>
</div>
<div className={styles.content}>
{this.state.isEditing ? (
<EditableCommentContent
editComment={this.editComment}
notify={notify}
comment={comment}
currentUser={currentUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
parentId={parentId}
stopEditing={this.stopEditing}
/>
) : (
<div>
<Slot
fill="commentContent"
className="talk-stream-comment-content"
defaultComponent={CommentContent}
{...slotProps}
queryData={queryData}
slotSize={1}
/>
</div>
)}
</div>
<div className={cn(styles.footer, 'talk-stream-comment-footer')}>
{isActive && (
<div className={'talk-stream-comment-actions-container'}>
<div className="talk-embed-stream-comment-actions-container-left commentActionsLeft comment__action-container">
<Slot
fill="commentReactions"
{...slotProps}
queryData={queryData}
inline
/>
{!disableReply && (
<ActionButton>
<ReplyButton
onClick={this.showReplyBox}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
/>
</ActionButton>
)}
</div>
<div className="talk-embed-stream-comment-actions-container-right commentActionsRight comment__action-container">
<Slot
fill="commentActions"
wrapperComponent={ActionButton}
{...slotProps}
queryData={queryData}
inline
/>
<ActionButton>
<FlagComment
flaggedByCurrentUser={!!myFlag}
flag={myFlag}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
notify={notify}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser}
/>
</ActionButton>
</div>
</div>
<Slot
className={styles.commentInfoBar}
fill="commentInfoBar"
{...slotProps}
queryData={queryData}
/>
{isActive &&
(currentUser && comment.user.id === currentUser.id) && (
/* User can edit/delete their own comment for a short window after posting */
<span className={cn(styles.topRight)}>
{this.state.isEditable && (
<a
className={cn(styles.link, {
[styles.active]: this.state.isEditing,
})}
onClick={this.onClickEdit}
>
Edit
</a>
)}
</span>
)}
</div>
{!isActive && <InactiveCommentLabel status={comment.status} />}
</div>
</div>
{activeReplyBox === comment.id ? (
<ReplyBox
commentPostedHandler={this.commentPostedHandler}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
setActiveReplyBox={setActiveReplyBox}
parentId={depth < THREADING_LEVEL ? comment.id : parentId}
notify={notify}
postComment={postComment}
currentUser={currentUser}
assetId={asset.id}
/>
) : null}
<TransitionGroup>
{view.map(reply => {
return (
<CommentContainer
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
<div className={styles.content}>
{this.state.isEditing ? (
<EditableCommentContent
editComment={this.editComment}
notify={notify}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
root={root}
comment={comment}
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
loadMore={loadMore}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
showSignInDialog={showSignInDialog}
liveUpdates={liveUpdates}
reactKey={reply.id}
key={reply.id}
comment={reply}
emit={emit}
parentId={parentId}
stopEditing={this.stopEditing}
/>
);
})}
</TransitionGroup>
<div className="talk-load-more-replies">
<LoadMore
topLevel={false}
replyCount={moreRepliesCount}
moreComments={hasMoreComments}
loadMore={this.loadNewReplies}
loadingState={loadingState}
/>
) : (
<div>
<Slot
fill="commentContent"
className="talk-stream-comment-content"
defaultComponent={CommentContent}
{...slotProps}
queryData={queryData}
slotSize={1}
/>
</div>
)}
</div>
<div className={cn(styles.footer, 'talk-stream-comment-footer')}>
{isActive && (
<div className={'talk-stream-comment-actions-container'}>
<div className="talk-embed-stream-comment-actions-container-left commentActionsLeft comment__action-container">
<Slot
fill="commentReactions"
{...slotProps}
queryData={queryData}
inline
/>
{!disableReply && (
<ActionButton>
<ReplyButton
onClick={this.showReplyBox}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
/>
</ActionButton>
)}
</div>
<div className="talk-embed-stream-comment-actions-container-right commentActionsRight comment__action-container">
<Slot
fill="commentActions"
wrapperComponent={ActionButton}
{...slotProps}
queryData={queryData}
inline
/>
<ActionButton>
<FlagComment
flaggedByCurrentUser={!!myFlag}
flag={myFlag}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
notify={notify}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser}
/>
</ActionButton>
</div>
</div>
)}
</div>
</div>
</div>
);
}
render() {
const {
depth,
comment,
activeReplyBox,
highlighted,
animateEnter,
} = this.props;
if (!highlighted && this.commentIsRejected(comment)) {
return <CommentTombstone action="reject" onUndo={this.undoStatus} />;
}
if (this.commentIsIgnored(comment)) {
return <CommentTombstone action="ignore" />;
}
const rootClassName = cn(
'talk-stream-comment-wrapper',
`talk-stream-comment-wrapper-level-${depth}`,
styles.root,
styles[`rootLevel${depth}`],
{
...this.getConditionalClassNames(),
[styles.enter]: animateEnter,
}
);
const id = `c_${comment.id}`;
return (
<div className={rootClassName} id={id}>
{this.renderComment()}
{activeReplyBox === comment.id && this.renderReplyBox()}
{this.renderRepliesContainer()}
</div>
);
}
}
// return whether the comment is editable
@@ -1,3 +1,18 @@
.buttonContainer {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.button {
float: right;
padding: 5px 10px;
background: rgb(105, 105, 105);
color: #FFF;
border: none;
border-radius: 2px;
}
/**
* Example loading state animations on the button.
*/
@@ -13,7 +13,7 @@ import DraftArea from '../containers/DraftArea';
/**
* Common UI for Creating or Editing a Comment
*/
export class CommentForm extends React.Component {
class CommentForm extends React.Component {
static propTypes = {
charCountEnable: PropTypes.bool.isRequired,
maxCharCount: PropTypes.number,
@@ -50,6 +50,11 @@ export class CommentForm extends React.Component {
onCancel: PropTypes.func,
state: PropTypes.string,
loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']),
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
isReply: PropTypes.bool,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
};
static get defaultProps() {
return {
@@ -87,6 +92,8 @@ export class CommentForm extends React.Component {
charCountEnable,
body,
loadingState,
comment,
root,
} = this.props;
const length = body.length;
@@ -104,6 +111,8 @@ export class CommentForm extends React.Component {
return (
<div>
<DraftArea
root={root}
comment={comment}
id={this.props.bodyInputId}
label={this.props.bodyLabel}
value={body}
@@ -112,8 +121,11 @@ export class CommentForm extends React.Component {
disabled={disableTextArea}
charCountEnable={this.props.charCountEnable}
maxCharCount={this.props.maxCharCount}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
isReply={this.props.isReply}
/>
<div className={`${name}-button-container`}>
<div className={cn(styles.buttonContainer, `${name}-button-container`)}>
{this.props.buttonContainerStart}
{typeof this.props.onCancel === 'function' && (
<Button
@@ -130,6 +142,7 @@ export class CommentForm extends React.Component {
disableSubmitButton ? 'lightGrey' : this.props.submitButtonCStyle
}
className={cn(
styles.button,
`${name}-button`,
submitButtonClassName,
this.getButtonClassName()
@@ -144,3 +157,5 @@ export class CommentForm extends React.Component {
);
}
}
export default CommentForm;
@@ -0,0 +1,12 @@
.textArea {
width: 100%;
}
.charCount {
color: #ccc;
text-align: right;
}
.charMax {
color: #d50000;
}
@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
import Slot from 'coral-framework/components/Slot';
import DraftAreaContent from './DraftAreaContent';
import styles from './DraftArea.css';
// TODO: (kiwi) Need to adapt CSS classes post refactor to match the rest.
@@ -12,9 +14,15 @@ import Slot from 'coral-framework/components/Slot';
export default class DraftArea extends React.Component {
renderCharCount() {
const { value, maxCharCount } = this.props;
const className = cn('talk-plugin-commentbox-char-count', {
['talk-plugin-commentbox-char-max']: value.length > maxCharCount,
});
const className = cn(
styles.charCount,
'talk-plugin-commentbox-char-count',
{
[`${styles.charMax} talk-plugin-commentbox-char-max`]:
value.length > maxCharCount,
}
);
const remaining = maxCharCount - value.length;
return (
@@ -35,22 +43,36 @@ export default class DraftArea extends React.Component {
charCountEnable,
maxCharCount,
onChange,
queryData,
isReply,
} = this.props;
const tASettings = {
value,
placeholder,
id,
onChange,
rows,
disabled,
isReply,
};
return (
<div>
<div className={'talk-plugin-commentbox-container'}>
<div
className={cn(styles.container, 'talk-plugin-commentbox-container')}
>
<label htmlFor={id} className="screen-reader-text" aria-hidden={true}>
{label}
</label>
<textarea
className={'talk-plugin-commentbox-textarea'}
value={value}
placeholder={placeholder}
id={id}
onChange={onChange}
rows={rows}
disabled={disabled}
<Slot
fill="draftArea"
defaultComponent={DraftAreaContent}
className={styles.content}
queryData={queryData}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
{...tASettings}
/>
<Slot fill="commentInputArea" />
</div>
@@ -74,4 +96,8 @@ DraftArea.propTypes = {
onChange: PropTypes.func,
disabled: PropTypes.bool,
rows: PropTypes.number,
queryData: PropTypes.object.isRequired,
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
isReply: PropTypes.bool,
};
@@ -0,0 +1,12 @@
.content {
color: #262626;
flex: 1;
padding: 1em;
min-height: 100px;
margin-top: 10px;
font-size: 16px;
border: 1px solid #9E9E9E;
width: 100%;
box-sizing: border-box;
height: 100%;
}
@@ -0,0 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './DraftAreaContent.css';
const DraftAreaContent = ({
value,
placeholder,
id,
onChange,
rows,
disabled,
}) => (
<textarea
className={cn(styles.content, 'talk-plugin-commentbox-textarea')}
value={value}
placeholder={placeholder}
id={id}
onChange={e => onChange(e.target.value)}
rows={rows}
disabled={disabled}
/>
);
DraftAreaContent.defaultProps = {
rows: 3,
};
DraftAreaContent.propTypes = {
id: PropTypes.string,
value: PropTypes.string,
placeholder: PropTypes.string,
onChange: PropTypes.func,
disabled: PropTypes.bool,
rows: PropTypes.number,
};
export default DraftAreaContent;
@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { notifyForNewCommentStatus } from '../helpers';
import { CommentForm } from './CommentForm';
import CommentForm from '../containers/CommentForm';
import styles from './Comment.css';
import { CountdownSeconds } from './CountdownSeconds';
import { getEditableUntilDate } from './util';
@@ -14,11 +14,11 @@ import t from 'coral-framework/services/i18n';
/**
* Renders a Comment's body in such a way that the end-user can edit it and save changes
*/
export class EditableCommentContent extends React.Component {
class EditableCommentContent extends React.Component {
static propTypes = {
// show notification to the user (e.g. for errors)
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
// comment that is being edited
comment: PropTypes.shape({
id: PropTypes.string,
@@ -53,6 +53,8 @@ export class EditableCommentContent extends React.Component {
this.state = {
body: props.comment.body,
loadingState: '',
// data: {@object} contains data that might be useful for plugins, metadata, etc
data: {},
};
}
componentDidMount() {
@@ -72,8 +74,14 @@ export class EditableCommentContent extends React.Component {
}
}
handleBodyChange = body => {
this.setState({ body });
handleBodyChange = (body, data) => {
this.setState(state => ({
body,
data: {
...state.data,
...data,
},
}));
};
handleSubmit = async () => {
@@ -88,9 +96,16 @@ export class EditableCommentContent extends React.Component {
if (typeof editComment !== 'function') {
return;
}
let input = {
body: this.state.body,
...this.state.data,
};
let response;
try {
response = await editComment({ body: this.state.body });
response = await editComment(input);
if (!this.unmounted) {
this.setState({ loadingState: 'success' });
}
@@ -125,6 +140,8 @@ export class EditableCommentContent extends React.Component {
return (
<div className={styles.editCommentForm}>
<CommentForm
root={this.props.root}
comment={this.props.comment}
defaultValue={this.props.comment.body}
bodyInputId={id}
charCountEnable={this.props.charCountEnable}
@@ -180,3 +197,5 @@ export class EditableCommentContent extends React.Component {
);
}
}
export default EditableCommentContent;
@@ -0,0 +1,3 @@
.container {
margin-top: 10px;
}
@@ -1,11 +1,13 @@
import React, { Component } from 'react';
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import CommentBox from '../containers/CommentBox';
import styles from './ReplyBox.css';
// TODO: (kiwi) Need to adapt CSS classes post refactor to match the rest.
const name = 'talk-plugin-replies';
class ReplyBox extends Component {
class ReplyBox extends React.Component {
componentDidMount() {
// TODO: (kiwi) This does not follow best practices, better to move this logic into the component.
document.getElementById(`comment-draft_${this.props.parentId}`).focus();
@@ -17,7 +19,6 @@ class ReplyBox extends Component {
render() {
const {
styles,
postComment,
assetId,
currentUser,
@@ -26,10 +27,14 @@ class ReplyBox extends Component {
commentPostedHandler,
maxCharCount,
charCountEnable,
comment,
root,
} = this.props;
return (
<div className={`${name}-textarea`} style={styles && styles.container}>
<div className={cn(styles.container, `${name}-textarea`)}>
<CommentBox
root={root}
comment={comment}
maxCharCount={maxCharCount}
charCountEnable={charCountEnable}
commentPostedHandler={commentPostedHandler}
@@ -57,6 +62,8 @@ ReplyBox.propTypes = {
assetId: PropTypes.string.isRequired,
currentUser: PropTypes.object,
styles: PropTypes.object,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
};
export default ReplyBox;
@@ -12,15 +12,11 @@ import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBo
import t, { timeago } from 'coral-framework/services/i18n';
import CommentBox from '../containers/CommentBox';
import QuestionBox from '../../../components/QuestionBox';
import { isCommentActive } from 'coral-framework/utils';
import { Tab, TabCount, TabPane } from 'coral-ui';
import cn from 'classnames';
import get from 'lodash/get';
import {
getTopLevelParent,
attachCommentToParent,
} from '../../../graphql/utils';
import { reverseCommentParentTree } from '../../../graphql/utils';
import AllCommentsPane from './AllCommentsPane';
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
@@ -64,16 +60,10 @@ class Stream extends React.Component {
viewAllComments,
} = this.props;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
let topLevelComment = getTopLevelParent(comment);
if (topLevelComment) {
// Inactive comments can be viewed by moderators and admins (e.g. using permalinks).
const isInactive = !isCommentActive(comment.status);
if (comment.parent && isInactive) {
// the highlighted comment is not active and as such not in the replies, so we
// attach it to the right parent.
topLevelComment = attachCommentToParent(topLevelComment, comment);
}
let topLevelComment = null;
if (comment) {
// Reverse the comment tree that we get from bottom-top (comment -> parent) to top-bottom (parent -> comment)
topLevelComment = reverseCommentParentTree(comment);
}
return (
@@ -112,7 +102,7 @@ class Stream extends React.Component {
postComment={postComment}
asset={asset}
currentUser={currentUser}
highlighted={comment.id}
highlighted={comment}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadNewReplies}
@@ -296,6 +286,7 @@ class Stream extends React.Component {
{banned && <BannedAccount />}
{showCommentBox && (
<CommentBox
root={root}
notify={notify}
postComment={postComment}
appendItemArray={appendItemArray}
@@ -4,6 +4,9 @@ import Comment from '../components/Comment';
import { withFragments } from 'coral-framework/hocs';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
import { withSetCommentStatus } from 'coral-framework/graphql/mutations';
import { getDefinitionName } from 'coral-framework/utils';
import CommentBox from './CommentBox';
import ReplyBox from './ReplyBox';
import {
THREADING_LEVEL,
REPLY_COMMENTS_LOAD_DEPTH,
@@ -17,12 +20,12 @@ const slots = [
'commentInputDetailArea',
'commentInfoBar',
'commentActions',
'commentContent',
'commentReactions',
'commentAvatar',
'commentAuthorName',
'commentAuthorTags',
'commentTimestamp',
'commentContent',
];
/**
@@ -64,7 +67,7 @@ const withAnimateEnter = hoistStatics(BaseComponent => {
return WithAnimateEnter;
});
const singleCommentFragment = gql`
export const singleCommentFragment = gql`
fragment CoralEmbedStream_Comment_SingleComment on Comment {
id
body
@@ -95,7 +98,11 @@ const singleCommentFragment = gql`
editableUntil
}
${getSlotFragmentSpreads(slots, 'comment')}
...${getDefinitionName(CommentBox.fragments.comment)}
...${getDefinitionName(ReplyBox.fragments.comment)}
}
${CommentBox.fragments.comment}
${ReplyBox.fragments.comment}
`;
const withCommentFragments = withFragments({
@@ -107,7 +114,11 @@ const withCommentFragments = withFragments({
}
}
${getSlotFragmentSpreads(slots, 'root')}
...${getDefinitionName(CommentBox.fragments.root)}
...${getDefinitionName(ReplyBox.fragments.root)}
}
${CommentBox.fragments.root}
${ReplyBox.fragments.root}
`,
asset: gql`
fragment CoralEmbedStream_Comment_asset on Asset {
@@ -1,13 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import t, { timeago } from 'coral-framework/services/i18n';
import { can } from 'coral-framework/services/perms';
import { isSuspended } from 'coral-framework/utils/user';
import Slot from 'coral-framework/components/Slot';
import { connect } from 'react-redux';
import { CommentForm } from '../components/CommentForm';
import CommentForm from '../containers/CommentForm';
import { notifyForNewCommentStatus } from '../helpers';
// TODO: (kiwi) Need to adapt CSS classes post refactor to match the rest.
@@ -23,7 +21,8 @@ class CommentBox extends React.Component {
this.state = {
body: '',
loadingState: '',
// data: {@object} contains data that might be useful for plugins
data: {},
hooks: {
preSubmit: [],
postSubmit: [],
@@ -62,10 +61,13 @@ class CommentBox extends React.Component {
parent_id: parentId,
body: this.state.body,
tags: this.props.tags,
...this.state.data,
};
// Execute preSubmit Hooks
this.state.hooks.preSubmit.forEach(hook => hook(input));
this.state.hooks.preSubmit.forEach(hook =>
hook(input, this.handleBodyChange)
);
this.setState({ loadingState: 'loading' });
postComment(input, 'comments')
@@ -75,7 +77,9 @@ class CommentBox extends React.Component {
const actions = data.createComment.actions;
// Execute postSubmit Hooks
this.state.hooks.postSubmit.forEach(hook => hook(data));
this.state.hooks.postSubmit.forEach(hook =>
hook(data, this.handleBodyChange)
);
notifyForNewCommentStatus(notify, postedComment.status, actions);
@@ -88,8 +92,14 @@ class CommentBox extends React.Component {
});
};
handleBodyChange = body => {
this.setState({ body });
handleBodyChange = (body, data) => {
this.setState(state => ({
body,
data: {
...state.data,
...data,
},
}));
};
registerHook = (hookType = '', hook = () => {}) => {
@@ -140,7 +150,14 @@ class CommentBox extends React.Component {
};
render() {
const { isReply, maxCharCount, assetId, parentId } = this.props;
const {
isReply,
maxCharCount,
assetId,
parentId,
comment,
root,
} = this.props;
let { onCancel } = this.props;
if (isReply && typeof onCancel !== 'function') {
@@ -158,6 +175,8 @@ class CommentBox extends React.Component {
return (
<div>
<CommentForm
root={root}
comment={comment}
defaultValue={this.props.defaultValue}
bodyLabel={isReply ? t('comment_box.reply') : t('comment.comment')}
maxCharCount={maxCharCount}
@@ -165,6 +184,9 @@ class CommentBox extends React.Component {
bodyPlaceholder={t('comment.comment')}
bodyInputId={id}
body={this.state.body}
registerHook={this.registerHook}
unregisterHook={this.unregisterHook}
isReply={isReply}
buttonContainerStart={
<Slot
fill="commentInputDetailArea"
@@ -199,8 +221,12 @@ CommentBox.propTypes = {
canPost: PropTypes.bool,
notify: PropTypes.func.isRequired,
tags: PropTypes.array,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
};
CommentBox.fragments = CommentForm.fragments;
const mapStateToProps = state => ({
tags: state.stream.commentBoxTags,
});
@@ -0,0 +1,9 @@
import React from 'react';
import CommentForm from '../components/CommentForm';
import DraftArea from './DraftArea';
const CommentFormContainer = props => <CommentForm {...props} />;
CommentFormContainer.fragments = DraftArea.fragments;
export default CommentFormContainer;
@@ -1,13 +1,16 @@
import React from 'react';
import { gql } from 'react-apollo';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
import PropTypes from 'prop-types';
import DraftArea from '../components/DraftArea';
import withFragments from 'coral-framework/hocs/withFragments';
const STORAGE_PATH = 'DraftArea';
/**
* An enhanced textarea to make comment drafts.
*/
export default class DraftAreaContainer extends React.Component {
class DraftAreaContainer extends React.Component {
constructor(props, context) {
super(props, context);
this.initValue();
@@ -24,8 +27,8 @@ export default class DraftAreaContainer extends React.Component {
return `${STORAGE_PATH}_${this.props.id}`;
};
onChange = e => {
this.props.onChange && this.props.onChange(e.target.value);
onChange = (body, data) => {
this.props.onChange && this.props.onChange(body, data);
};
componentWillReceiveProps(nextProps) {
@@ -39,8 +42,11 @@ export default class DraftAreaContainer extends React.Component {
}
render() {
const queryData = { comment: this.props.comment, root: this.props.root };
return (
<DraftArea
queryData={queryData}
value={this.props.value}
placeholder={this.props.placeholder}
id={this.props.id}
@@ -50,6 +56,9 @@ export default class DraftAreaContainer extends React.Component {
charCountEnable={this.props.charCountEnable}
maxCharCount={this.props.maxCharCount}
label={this.props.label}
registerHook={this.props.registerHook}
unregisterHook={this.props.unregisterHook}
isReply={this.props.isReply}
/>
);
}
@@ -71,4 +80,26 @@ DraftAreaContainer.propTypes = {
disabled: PropTypes.bool,
rows: PropTypes.number,
label: PropTypes.string.isRequired,
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
isReply: PropTypes.bool,
root: PropTypes.object.isRequired,
comment: PropTypes.object,
};
const slots = ['draftArea'];
export default withFragments({
root: gql`
fragment TalkEmbedStream_DraftArea_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
}
`,
comment: gql`
fragment TalkEmbedStream_DraftArea_comment on Comment {
__typename
${getSlotFragmentSpreads(slots, 'comment')}
}
`,
})(DraftAreaContainer);
@@ -0,0 +1,11 @@
import React from 'react';
import EditableCommentContent from '../components/EditableCommentContent';
import CommentForm from './CommentForm';
const EditableCommentContentContainer = props => (
<EditableCommentContent {...props} />
);
EditableCommentContentContainer.fragments = CommentForm.fragments;
export default EditableCommentContentContainer;
@@ -0,0 +1,9 @@
import React from 'react';
import ReplyBox from '../components/ReplyBox';
import CommentBox from './CommentBox';
const ReplyBoxContainer = props => <ReplyBox {...props} />;
ReplyBoxContainer.fragments = CommentBox.fragments;
export default ReplyBoxContainer;
@@ -24,7 +24,7 @@ import {
viewAllComments,
} from '../../../actions/stream';
import Stream from '../components/Stream';
import Comment from './Comment';
import { default as Comment, singleCommentFragment } from './Comment';
import { withFragments, withEmit } from 'coral-framework/hocs';
import {
getDefinitionName,
@@ -282,7 +282,7 @@ StreamContainer.propTypes = {
previousTab: PropTypes.string,
};
const commentFragment = gql`
const streamCommentFragment = gql`
fragment CoralEmbedStream_Stream_comment on Comment {
id
status
@@ -294,6 +294,18 @@ const commentFragment = gql`
${Comment.fragments.comment}
`;
const streamSingleCommentFragment = gql`
fragment CoralEmbedStream_Stream_singleComment on Comment {
id
status
user {
id
}
...${getDefinitionName(singleCommentFragment)}
}
${singleCommentFragment}
`;
const COMMENTS_ADDED_SUBSCRIPTION = gql`
subscription CommentAdded($assetId: ID!, $excludeIgnored: Boolean) {
commentAdded(asset_id: $assetId) {
@@ -303,7 +315,7 @@ const COMMENTS_ADDED_SUBSCRIPTION = gql`
...CoralEmbedStream_Stream_comment
}
}
${commentFragment}
${streamCommentFragment}
`;
const COMMENTS_EDITED_SUBSCRIPTION = gql`
@@ -351,7 +363,7 @@ const LOAD_MORE_QUERY = gql`
endCursor
}
}
${commentFragment}
${streamCommentFragment}
`;
const slots = [
@@ -398,7 +410,7 @@ const fragments = {
${nest(
`
parent {
...CoralEmbedStream_Stream_comment
...CoralEmbedStream_Stream_singleComment
...nest
}
`,
@@ -437,7 +449,8 @@ const fragments = {
...${getDefinitionName(Comment.fragments.asset)}
}
${Comment.fragments.asset}
${commentFragment}
${streamCommentFragment}
${streamSingleCommentFragment}
`,
};
@@ -5,7 +5,6 @@
* {
font-family: inherit;
font-style: inherit;
}
html, body {
@@ -113,53 +112,6 @@ body {
position: relative;
}
/* Comment Box Styles */
.talk-plugin-commentbox-container {
display: flex;
width: 100%;
}
.talk-plugin-commentbox-textarea {
color: #262626;
flex: 1;
padding: 1em;
min-height: 100px;
margin-top: 10px;
font-size: 16px;
border: 1px solid #9E9E9E;
}
.talk-plugin-commentbox-button-container {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.talk-plugin-commentbox-button {
float: right;
margin-top: 10px;
padding: 5px 10px;
background: rgb(105, 105, 105);
color: #FFF;
border: none;
border-radius: 2px;
}
.talk-plugin-commentbox-username {
width: 50%;
padding-left: 5px;
margin-bottom: 5px;
}
.talk-plugin-commentbox-char-count {
color: #ccc;
text-align: right;
}
.talk-plugin-commentbox-char-max {
color: #d50000;
}
/* Comment styles */
.comment {
margin-bottom: 10px;
+2 -1
View File
@@ -59,7 +59,8 @@ export function createClient(options = {}) {
if (authToken) {
req.options.headers['authorization'] = `Bearer ${authToken}`;
}
// To debug queries add print(req.request.query) and import it from graphql/language/printer
// console.log(print(req.request.query));
next();
},
},
+5
View File
@@ -50,6 +50,11 @@
color: #00a291;
}
.input:disabled + .checkbox:before {
color: #e5e5e5;
cursor: default;
}
.input:focus + .checkbox:before {
color: #00a291;
}
+8 -2
View File
@@ -1,8 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './Spinner.css';
import cn from 'classnames';
const Spinner = () => (
<div className={styles.container}>
const Spinner = ({ className }) => (
<div className={cn(styles.container, className)}>
<svg
className={styles.spinner}
width="40px"
@@ -23,4 +25,8 @@ const Spinner = () => (
</div>
);
Spinner.propTypes = {
className: PropTypes.string,
};
export default Spinner;
+11
View File
@@ -48,6 +48,17 @@ const CONFIG = {
// request all of the records. Otherwise, minimum limits of 0 are enforced.
ALLOW_NO_LIMIT_QUERIES: process.env.TALK_ALLOW_NO_LIMIT_QUERIES === 'TRUE',
// LOGGING_LEVEL specifies the logging level used by the bunyan logger.
LOGGING_LEVEL: ['fatal', 'error', 'warn', 'info', 'debug', 'trace'].includes(
process.env.TALK_LOGGING_LEVEL
)
? process.env.TALK_LOGGING_LEVEL
: 'info',
// REVISION_HASH when using the docker build will contain the build hash that
// it was built at.
REVISION_HASH: process.env.REVISION_HASH,
//------------------------------------------------------------------------------
// JWT based configuration
//------------------------------------------------------------------------------
@@ -555,3 +555,14 @@ This is a **Build Variable** and must be consumed during build. If using the
image you can specify it with `--build-arg TALK_REPLY_COMMENTS_LOAD_DEPTH=3`.
Specifies the initial replies to load for a comment. (Default `3`)
## TALK_LOGGING_LEVEL
Sets the logging level for the context logger (from [Bunyan](https://github.com/trentm/node-bunyan)) that will be phased in to replace most existing `debug()` calls. Supports the following values:
- `fatal`
- `error`
- `warn`
- `info`
- `debug`
- `trace`
+20
View File
@@ -140,6 +140,10 @@ anything. You need to enable one of the `talk-plugin-notifications-category-*` p
```
{:.no-copy}
Configuration:
- `DISABLE_REQUIRE_EMAIL_VERIFICATIONS` - When `TRUE`, it will disable the verification email check before sending notifications for those emails. **Note that organizations implementing a custom authentication system _must_ disable this feature, as they don't use our integrated auth**. (Default `FALSE`).
### talk-plugin-notifications-category-reply
{:.param}
@@ -147,3 +151,19 @@ Source: [plugins/talk-plugin-notifications-category-reply](https://github.com/co
Replies made to each user will trigger an email to be sent with the notification
details if enabled.
### talk-plugin-notifications-category-featured
{:.param}
Source: [plugins/talk-plugin-notifications-category-featured](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications-category-featured){:target="_blank"}
When a comment is featured (via the `talk-plugin-featured-comments` plugin), the
user will receive a notification email.
### talk-plugin-notifications-category-staff
{:.param}
Source: [plugins/talk-plugin-notifications-category-staff](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications-category-staff){:target="_blank"}
Replies made to each user by a staff member will trigger an email to be sent
with the notification details if enabled.
+14
View File
@@ -56,12 +56,25 @@ const closeNow = async (ctx, id) =>
}
);
/**
* scrapeAsset will force scrape an asset.
*
* @param {Object} ctx graphql context
* @param {String} id the asset's id to scrape
*/
const scrapeAsset = async (ctx, id) => {
const { services: { Scraper } } = ctx;
return Scraper.create({ id });
};
module.exports = ctx => {
let mutators = {
Asset: {
updateSettings: () => Promise.reject(errors.ErrNotAuthorized),
updateStatus: () => Promise.reject(errors.ErrNotAuthorized),
closeNow: () => Promise.reject(errors.ErrNotAuthorized),
scrape: () => Promise.reject(errors.ErrNotAuthorized),
},
};
@@ -75,6 +88,7 @@ module.exports = ctx => {
mutators.Asset.updateStatus = (id, status) =>
updateStatus(ctx, id, status);
mutators.Asset.closeNow = id => closeNow(ctx, id);
mutators.Asset.scrape = id => scrapeAsset(ctx, id);
}
}
+45 -28
View File
@@ -12,12 +12,9 @@ const {
ADD_COMMENT_TAG,
EDIT_COMMENT,
} = require('../../perms/constants');
const debug = require('debug')('talk:graph:mutators:comment');
const resolveTagsForComment = async (
{ user, loaders: { Tags } },
{ asset_id, tags = [] }
) => {
const resolveTagsForComment = async (ctx, { asset_id, tags = [] }) => {
const { user, loaders: { Tags } } = ctx;
const item_type = 'COMMENTS';
// Handle Tags
@@ -46,6 +43,7 @@ const resolveTagsForComment = async (
// Add the staff tag for comments created as a staff member.
if (user.can(ADD_COMMENT_TAG)) {
ctx.log.info({ author_id: user.id }, 'Added staff tag to comment');
tags.push(
TagsService.newTagLink(user, {
name: 'STAFF',
@@ -61,7 +59,7 @@ const resolveTagsForComment = async (
* adjustKarma will adjust the affected user's karma depending on the moderators
* action.
*/
const adjustKarma = (Comments, id, status) => async () => {
const adjustKarma = (ctx, Comments, id, status) => async () => {
try {
// Use the dataloader to get the comment that was just moderated and
// get the flag user's id's so we can adjust their karma too.
@@ -86,17 +84,27 @@ const adjustKarma = (Comments, id, status) => async () => {
}),
]);
debug(`Comment[${id}] by User[${comment.author_id}] was Status[${status}]`);
ctx.log.info(
{
state: {
comment_id: id,
author_id: comment.author_id,
status: status,
},
},
'Processing karma modification'
);
switch (status) {
case 'REJECTED':
// Reduce the user's karma.
debug(`CommentUser[${comment.author_id}] had their karma reduced`);
ctx.log.info('Comment author had their karma reduced');
// Decrease the flag user's karma, the moderator disagreed with this
// action.
debug(
`FlaggingUser[${flagUserIDs.join(', ')}] had their karma increased`
ctx.log.info(
{ flagUserIDs },
'Flagging users had their karma increased'
);
await Promise.all([
KarmaService.modifyUser(comment.author_id, -1, 'comment'),
@@ -107,13 +115,11 @@ const adjustKarma = (Comments, id, status) => async () => {
case 'ACCEPTED':
// Increase the user's karma.
debug(`CommentUser[${comment.author_id}] had their karma increased`);
ctx.log.info('Comment author had their karma increased');
// Increase the flag user's karma, the moderator agreed with this
// action.
debug(
`FlaggingUser[${flagUserIDs.join(', ')}] had their karma reduced`
);
ctx.log.info({ flagUserIDs }, `Flagging users had their karma reduced`);
await Promise.all([
KarmaService.modifyUser(comment.author_id, 1, 'comment'),
KarmaService.modifyUser(flagUserIDs, -1, 'flag', true),
@@ -140,7 +146,7 @@ const adjustKarma = (Comments, id, status) => async () => {
* @return {Promise} resolves to the created comment
*/
const createComment = async (
context,
ctx,
{
tags = [],
body,
@@ -150,10 +156,10 @@ const createComment = async (
metadata = {},
}
) => {
const { user, loaders: { Comments }, pubsub } = context;
const { user, loaders: { Comments }, pubsub } = ctx;
// Resolve the tags for the comment.
tags = await resolveTagsForComment(context, { asset_id, tags });
tags = await resolveTagsForComment(ctx, { asset_id, tags });
let comment = await CommentsService.publicCreate({
body,
@@ -165,6 +171,11 @@ const createComment = async (
metadata,
});
ctx.log.info(
{ comment_id: comment.id, comment_status: status },
'Created comment'
);
// If the loaders are present, clear the caches for these values because we
// just added a new comment, hence the counts should be updated. We should
// perform these increments in the event that we do have a new comment that
@@ -228,12 +239,14 @@ const createActions = async (item_id, actions = []) =>
/**
* Sets the status of a comment
* @param {Object} context graphql context
* @param {Object} ctx graphql context
* @param {String} comment comment in graphql context
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
*/
const setStatus = async ({ user, loaders: { Comments } }, { id, status }) => {
const setStatus = async (ctx, { id, status }) => {
const { user, loaders: { Comments } } = ctx;
let comment = await CommentsService.pushStatus(
id,
status,
@@ -253,7 +266,7 @@ const setStatus = async ({ user, loaders: { Comments } }, { id, status }) => {
// postSetCommentStatus will use the arguments from the mutation and
// adjust the affected user's karma in the next tick.
process.nextTick(adjustKarma(Comments, id, status));
process.nextTick(adjustKarma(ctx, Comments, id, status));
return comment;
};
@@ -264,7 +277,10 @@ const setStatus = async ({ user, loaders: { Comments } }, { id, status }) => {
* @param {Object} edit describes how to edit the comment
* @param {String} edit.body the new Comment body
*/
const edit = async (ctx, { id, asset_id, edit: { body } }) => {
const editComment = async (
ctx,
{ id, asset_id, edit: { body, metadata = {} } }
) => {
const { connectors: { services: { Moderation } } } = ctx;
// Build up the new comment we're setting. We need to check this with
@@ -280,6 +296,7 @@ const edit = async (ctx, { id, asset_id, edit: { body } }) => {
author_id: ctx.user.id,
body,
status,
metadata,
});
// Create all the actions that were determined during the moderation check
@@ -292,7 +309,7 @@ const edit = async (ctx, { id, asset_id, edit: { body } }) => {
return comment;
};
module.exports = context => {
module.exports = ctx => {
let mutators = {
Comment: {
create: () => Promise.reject(errors.ErrNotAuthorized),
@@ -301,16 +318,16 @@ module.exports = context => {
},
};
if (context.user && context.user.can(CREATE_COMMENT)) {
mutators.Comment.create = comment => createPublicComment(context, comment);
if (ctx.user && ctx.user.can(CREATE_COMMENT)) {
mutators.Comment.create = comment => createPublicComment(ctx, comment);
}
if (context.user && context.user.can(SET_COMMENT_STATUS)) {
mutators.Comment.setStatus = action => setStatus(context, action);
if (ctx.user && ctx.user.can(SET_COMMENT_STATUS)) {
mutators.Comment.setStatus = action => setStatus(ctx, action);
}
if (context.user && context.user.can(EDIT_COMMENT)) {
mutators.Comment.edit = action => edit(context, action);
if (ctx.user && ctx.user.can(EDIT_COMMENT)) {
mutators.Comment.edit = action => editComment(ctx, action);
}
return mutators;
+6 -2
View File
@@ -15,6 +15,7 @@ const DontAgreeActionSummary = require('./dont_agree_action_summary');
const FlagAction = require('./flag_action');
const FlagActionSummary = require('./flag_action_summary');
const GenericUserError = require('./generic_user_error');
const LocalUserProfile = require('./local_user_profile');
const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const Settings = require('./settings');
@@ -24,8 +25,9 @@ const Tag = require('./tag');
const TagLink = require('./tag_link');
const User = require('./user');
const UserError = require('./user_error');
const UserState = require('./user_state');
const UsernameStatusHistory = require('./username_status_history');
const UserProfile = require('./user_profile');
const UserState = require('./user_state');
const ValidationUserError = require('./validation_user_error');
const plugins = require('../../services/plugins');
@@ -46,6 +48,7 @@ let resolvers = {
FlagAction,
FlagActionSummary,
GenericUserError,
LocalUserProfile,
RootMutation,
RootQuery,
Settings,
@@ -55,8 +58,9 @@ let resolvers = {
TagLink,
User,
UserError,
UserState,
UsernameStatusHistory,
UserProfile,
UserState,
ValidationUserError,
};
+7
View File
@@ -0,0 +1,7 @@
const { property } = require('lodash');
const LocalUserProfile = {
confirmedAt: property('metadata.confirmed_at'),
};
module.exports = LocalUserProfile;
+5 -2
View File
@@ -13,10 +13,10 @@ const RootMutation = {
},
editComment: async (
_,
{ id, asset_id, edit: { body } },
{ id, asset_id, edit },
{ mutators: { Comment } }
) => ({
comment: await Comment.edit({ id, asset_id, edit: { body } }),
comment: await Comment.edit({ id, asset_id, edit }),
}),
createFlag: async (
_,
@@ -133,6 +133,9 @@ const RootMutation = {
revokeToken: async (_, { input }, { mutators: { Token } }) => {
await Token.revoke(input);
},
forceScrapeAsset: async (_, { id }, { mutators: { Asset } }) => {
await Asset.scrape(id);
},
};
module.exports = RootMutation;
+12
View File
@@ -0,0 +1,12 @@
const UserProfile = {
__resolveType({ provider }) {
switch (provider) {
case 'local':
return 'LocalUserProfile';
default:
return undefined;
}
},
};
module.exports = UserProfile;
+35 -1
View File
@@ -62,7 +62,7 @@ type Token {
jwt: String
}
type UserProfile {
interface UserProfile {
# The id is an identifier for the user profile (email, facebook id, etc)
id: String!
@@ -70,6 +70,31 @@ type UserProfile {
provider: String!
}
# DefaultUserProfile is a fallback if the type of UserProfile can't be
# determined (like if it was from a plugin that was removed).
type DefaultUserProfile implements UserProfile {
# The id is an identifier for the user profile (email, facebook id, etc)
id: String!
# name of the provider attached to the authentication mode
provider: String!
}
# LocalUserProfile is for a User who has an authentication profile linked to
# their email address.
type LocalUserProfile implements UserProfile {
# id is the User's email address.
id: String!
# name of the provider attached to the authentication mode, in this case,
# 'local'.
provider: String!
# confirmedAt is the Date that the user had their email address confirmed,
# which is null if it has not been verified.
confirmedAt: Date
}
# USER_STATUS_USERNAME is the different states that a username can be in.
enum USER_STATUS_USERNAME {
# UNSET is used when the username can be changed, and does not necessarily
@@ -1395,6 +1420,12 @@ type SetUserRoleResponse implements Response {
errors: [UserError!]
}
type ForceScrapeAssetResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# All mutations for the application are defined on this object.
type RootMutation {
@@ -1489,6 +1520,9 @@ type RootMutation {
# Stop Ignoring comments by another user.
stopIgnoringUser(id: ID!): StopIgnoringUserResponse
# forceScrapeAsset will force scrape the Asset with the given ID.
forceScrapeAsset(id: ID!): ForceScrapeAssetResponse
}
type UsernameChangedPayload {
+2
View File
@@ -27,6 +27,8 @@ module.exports = {
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
},
testResultsProcessor: process.env.JEST_REPORTER,
moduleNameMapper: {
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
+1
View File
@@ -217,6 +217,7 @@
"identity-obj-proxy": "^3.0.0",
"ip": "^1.1.5",
"jest": "^21.2.1",
"jest-junit": "^3.6.0",
"lint-staged": "^7.0.0",
"mocha": "^3.1.2",
"mocha-junit-reporter": "^1.12.1",
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -49,6 +49,6 @@ export default class CheckSpamHook extends React.Component {
CheckSpamHook.propTypes = {
notify: PropTypes.func.isRequired,
registerHook: PropTypes.func.isRequired,
unregisterHook: PropTypes.func.isRequired,
registerHook: PropTypes.func,
unregisterHook: PropTypes.func,
};
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -41,7 +41,7 @@ ResendEmailConfirmatonContainer.propTypes = {
success: PropTypes.bool.isRequired,
loading: PropTypes.bool.isRequired,
resendEmailConfirmation: PropTypes.func.isRequired,
errorMessage: PropTypes.string.isRequired,
errorMessage: PropTypes.string,
setView: PropTypes.func.isRequired,
email: PropTypes.string.isRequired,
};
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -1,4 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import Linkify from 'react-linkify';
const name = 'talk-plugin-comment-content';
@@ -21,4 +22,8 @@ const CommentContent = ({ comment }) => {
);
};
CommentContent.propTypes = {
comment: PropTypes.object.isRequired,
};
export default CommentContent;
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -3,7 +3,12 @@ import Comment from '../components/Comment';
import { withFragments } from 'plugin-api/beta/client/hocs';
import { getSlotFragmentSpreads } from 'plugin-api/beta/client/utils';
const slots = ['commentReactions', 'commentAuthorName', 'commentTimestamp'];
const slots = [
'commentReactions',
'commentAuthorName',
'commentTimestamp',
'commentContent',
];
export default withFragments({
root: gql`
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,74 @@
import React from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import Toggle from 'talk-plugin-notifications/client/components/Toggle';
import { t } from 'plugin-api/beta/client/services';
import { withFragments } from 'plugin-api/beta/client/hocs';
class ToggleContainer extends React.Component {
constructor(props) {
super(props);
props.setTurnOffInputFragment({ onFeatured: false });
if (this.getOnFeaturedSetting()) {
props.indicateOn();
}
}
componentWillReceiveProps(nextProps) {
const prevSetting = this.getOnFeaturedSetting(this.props);
const nextSetting = this.getOnFeaturedSetting(nextProps);
if (prevSetting && !nextSetting) {
nextProps.indicateOff();
} else if (!prevSetting && nextSetting) {
nextProps.indicateOn();
}
}
getOnFeaturedSetting = (props = this.props) =>
props.root.me.notificationSettings.onFeatured;
toggle = () => {
this.props.updateNotificationSettings({
onFeatured: !this.getOnFeaturedSetting(),
});
};
render() {
return (
<Toggle
checked={this.getOnFeaturedSetting()}
onChange={this.toggle}
disabled={this.props.disabled}
>
{t('talk-plugin-notifications-category-featured.toggle_description')}
</Toggle>
);
}
}
ToggleContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
indicateOn: PropTypes.func.isRequired,
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
withFragments({
root: gql`
fragment TalkNotificationsCategoryFeatured_Toggle_root on RootQuery {
me {
notificationSettings {
onFeatured
}
}
}
`,
})
);
export default enhance(ToggleContainer);
@@ -0,0 +1,33 @@
import { gql } from 'react-apollo';
export default {
mutations: {
UpdateNotificationSettings: ({
variables: { input },
state: { auth: { user: { id } } },
}) => ({
update: proxy => {
if (input.onFeatured === undefined) {
return;
}
const fragment = gql`
fragment TalkNotificationsCategoryFeatured_User_Fragment on User {
notificationSettings {
onFeatured
}
}
`;
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
notificationSettings: {
__typename: 'NotificationSettings',
onFeatured: input.onFeatured,
},
};
proxy.writeFragment({ fragment, id: fragmentId, data });
},
}),
},
};
@@ -0,0 +1,11 @@
import Toggle from './containers/Toggle';
import translations from './translations.yml';
import graphql from './graphql';
export default {
slots: {
notificationSettings: [Toggle],
},
translations,
...graphql,
};
@@ -0,0 +1,3 @@
en:
talk-plugin-notifications-category-featured:
toggle_description: My comment is featured
@@ -0,0 +1,107 @@
const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, { comment }) => {
// Check to see if this is a reply to an existing comment.
const commentID = get(comment, 'id', null);
if (commentID === null) {
ctx.log.info('could not get comment id');
return;
}
// Execute the graph request.
const reply = await ctx.graphql(
`
query GetAuthorUserMetadata($comment_id: ID!) {
comment(id: $comment_id) {
id
user {
id
notificationSettings {
onFeatured
}
}
}
}
`,
{ comment_id: commentID }
);
if (reply.errors) {
ctx.log.error({ err: reply.errors }, 'could not query for author metadata');
return;
}
// Check if the user has notifications enabled.
const enabled = get(
reply,
'data.comment.user.notificationSettings.onFeatured',
false
);
if (!enabled) {
return;
}
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
ctx.log.info('could not get comment user id');
return;
}
// The user does have notifications for featured comments enabled, queue the
// notification to be sent.
return { userID, date: comment.created_at, context: comment.id };
};
const hydrate = async (ctx, category, context) => {
const reply = await ctx.graphql(
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
id
asset {
title
url
}
}
}
`,
{ context }
);
if (reply.errors) {
throw reply.errors;
}
const comment = get(reply, 'data.comment');
const headline = get(comment, 'asset.title', null);
const assetURL = get(comment, 'asset.url', null);
const permalink = `${assetURL}?commentId=${comment.id}`;
return [headline, permalink];
};
const handler = {
handle,
category: 'featured',
event: 'commentFeatured',
hydrate,
};
module.exports = {
typeDefs: `
type NotificationSettings {
onFeatured: Boolean!
}
input NotificationSettingsInput {
onFeatured: Boolean
}
`,
resolvers: {
NotificationSettings: {
// onFeatured returns false by default if not specified.
onFeatured: settings => get(settings, 'onFeatured', false),
},
},
translations: path.join(__dirname, 'translations.yml'),
notifications: [handler],
};
@@ -0,0 +1,6 @@
en:
talk-plugin-notifications:
categories:
featured:
subject: "One of your comments was featured on [{0}]"
body: "{0}\nA member of our team has selected this comment to be featured for other readers: {1}"
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -36,7 +36,11 @@ class ToggleContainer extends React.Component {
render() {
return (
<Toggle checked={this.getOnReplySetting()} onChange={this.toggle}>
<Toggle
checked={this.getOnReplySetting()}
onChange={this.toggle}
disabled={this.props.disabled}
>
{t('talk-plugin-notifications-category-reply.toggle_description')}
</Toggle>
);
@@ -50,6 +54,7 @@ ToggleContainer.propTypes = {
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
@@ -1,20 +1,16 @@
const { graphql } = require('graphql');
const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, comment) => {
const { connectors: { graph: { schema } } } = ctx;
// Check to see if this is a reply to an existing comment.
const parentID = get(comment, 'parent_id', null);
if (parentID === null) {
ctx.log.debug('could not get parent comment id');
ctx.log.info('could not get parent comment id');
return;
}
// Execute the graph request.
const reply = await graphql(
schema,
const reply = await ctx.graphql(
`
query GetAuthorUserMetadata($comment_id: ID!) {
comment(id: $comment_id) {
@@ -28,8 +24,6 @@ const handle = async (ctx, comment) => {
}
}
`,
{},
ctx,
{ comment_id: parentID }
);
if (reply.errors) {
@@ -49,14 +43,14 @@ const handle = async (ctx, comment) => {
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
ctx.log.debug('could not get parent comment user id');
ctx.log.info('could not get parent comment user id');
return;
}
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (userID === get(comment, 'author_id')) {
ctx.log.debug('user id of parent comment is the same as the new comment');
ctx.log.info('user id of parent comment is the same as the new comment');
return;
}
@@ -66,10 +60,7 @@ const handle = async (ctx, comment) => {
};
const hydrate = async (ctx, category, context) => {
const { connectors: { graph: { schema } } } = ctx;
const reply = await graphql(
schema,
const reply = await ctx.graphql(
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
@@ -84,8 +75,6 @@ const hydrate = async (ctx, category, context) => {
}
}
`,
{},
ctx,
{ context }
);
if (reply.errors) {
@@ -2,5 +2,5 @@ en:
talk-plugin-notifications:
categories:
reply:
subject: "Some has replied to your comment on [{0}]"
subject: "Someone has replied to your comment on {0}"
body: "{0}\n{1} replied to your comment: {2}"
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,74 @@
import React from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import Toggle from 'talk-plugin-notifications/client/components/Toggle';
import { t } from 'plugin-api/beta/client/services';
import { withFragments } from 'plugin-api/beta/client/hocs';
class ToggleContainer extends React.Component {
constructor(props) {
super(props);
props.setTurnOffInputFragment({ onStaffReply: false });
if (this.getOnReplySetting()) {
props.indicateOn();
}
}
componentWillReceiveProps(nextProps) {
const prevSetting = this.getOnReplySetting(this.props);
const nextSetting = this.getOnReplySetting(nextProps);
if (prevSetting && !nextSetting) {
nextProps.indicateOff();
} else if (!prevSetting && nextSetting) {
nextProps.indicateOn();
}
}
getOnReplySetting = (props = this.props) =>
props.root.me.notificationSettings.onStaffReply;
toggle = () => {
this.props.updateNotificationSettings({
onStaffReply: !this.getOnReplySetting(),
});
};
render() {
return (
<Toggle
checked={this.getOnReplySetting()}
onChange={this.toggle}
disabled={this.props.disabled}
>
{t('talk-plugin-notifications-category-staff.toggle_description')}
</Toggle>
);
}
}
ToggleContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
indicateOn: PropTypes.func.isRequired,
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
withFragments({
root: gql`
fragment TalkNotificationsCategoryStaffReply_User_Fragment on RootQuery {
me {
notificationSettings {
onStaffReply
}
}
}
`,
})
);
export default enhance(ToggleContainer);
@@ -0,0 +1,33 @@
import { gql } from 'react-apollo';
export default {
mutations: {
UpdateNotificationSettings: ({
variables: { input },
state: { auth: { user: { id } } },
}) => ({
update: proxy => {
if (input.onStaffReply === undefined) {
return;
}
const fragment = gql`
fragment TalkNotificationsCategoryStaffReply_User_Fragment on User {
notificationSettings {
onStaffReply
}
}
`;
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
notificationSettings: {
__typename: 'NotificationSettings',
onStaffReply: input.onStaffReply,
},
};
proxy.writeFragment({ fragment, id: fragmentId, data });
},
}),
},
};
@@ -0,0 +1,11 @@
import Toggle from './containers/Toggle';
import translations from './translations.yml';
import graphql from './graphql';
export default {
slots: {
notificationSettings: [Toggle],
},
translations,
...graphql,
};
@@ -0,0 +1,3 @@
en:
talk-plugin-notifications-category-staff:
toggle_description: A staff member replies to my comment
@@ -0,0 +1,140 @@
const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, comment) => {
// Check to see if this is a reply to an existing comment.
const parentID = get(comment, 'parent_id', null);
if (parentID === null) {
ctx.log.info('could not get parent comment id');
return;
}
const authorID = get(comment, 'author_id', null);
if (authorID === null) {
ctx.log.error('could not get author id');
return;
}
// Execute the graph request.
const reply = await ctx.graphql(
`
query GetAuthorUserMetadata($comment_id: ID!, $author_id: ID!) {
author: user(id: $author_id) {
role
}
comment(id: $comment_id) {
id
user {
id
notificationSettings {
onStaffReply
}
}
}
}
`,
{ comment_id: parentID, author_id: authorID }
);
if (reply.errors) {
ctx.log.error({ err: reply.errors }, 'could not query for author metadata');
return;
}
// Check if the user has notifications enabled.
const enabled = get(
reply,
'data.comment.user.notificationSettings.onStaffReply',
false
);
if (!enabled) {
ctx.log.info('onStaffReply is false, will not send the notification');
return;
}
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
ctx.log.info('could not get parent comment user id');
return;
}
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (userID === authorID) {
ctx.log.info('user id of parent comment is the same as the new comment');
return;
}
// Check to see that this comment was indeed from a staff member.
const role = get(reply, 'data.author.role');
if (!['ADMIN', 'MODERATOR', 'STAFF'].includes(role)) {
ctx.log.info({ role }, 'reply author is not a staff member');
return;
}
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return { userID, date: comment.created_at, context: comment.id };
};
const hydrate = async (ctx, category, context) => {
const reply = await ctx.graphql(
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
id
asset {
title
url
}
user {
username
}
}
settings {
organizationName
}
}
`,
{ context }
);
if (reply.errors) {
throw reply.errors;
}
const comment = get(reply, 'data.comment');
const headline = get(comment, 'asset.title', null);
const replier = get(comment, 'user.username', null);
const assetURL = get(comment, 'asset.url', null);
const permalink = `${assetURL}?commentId=${comment.id}`;
const organizationName = get(reply, 'data.settings.organizationName', null);
return [headline, replier, organizationName, permalink];
};
const handler = {
handle,
category: 'staff',
event: 'commentAdded',
hydrate,
supersedesCategories: ['reply'],
};
module.exports = {
typeDefs: `
type NotificationSettings {
onStaffReply: Boolean!
}
input NotificationSettingsInput {
onStaffReply: Boolean
}
`,
resolvers: {
NotificationSettings: {
// onStaffReply returns false by default if not specified.
onStaffReply: settings => get(settings, 'onStaffReply', false),
},
},
translations: path.join(__dirname, 'translations.yml'),
notifications: [handler],
};
@@ -0,0 +1,6 @@
en:
talk-plugin-notifications:
categories:
staff:
subject: "Someone at {0} has replied to your comment"
body: "{0}\n{1} works for {2} and has replied to your comment: {3}"
@@ -1,3 +0,0 @@
{
"extends": "@coralproject/eslint-config-talk"
}
@@ -0,0 +1,42 @@
.root {
display: flex;
border: 1px solid #a8afb3;
border-radius: 2px;
margin: 16px 0;
p:first-of-type {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
}
.leftColumn {
width: 32px;
background-color: #787d80;
text-align: center;
padding-top: 6px;
font-size: 18px;
flex-shrink: 0;
&.error {
background-color: #fa6265;
}
&.success {
background-color: #00cd7e;
}
}
.icon {
color: white;
}
.rightColumn {
padding: 8px 12px 10px 12px;
font-size: 14px;
}
.title {
font-size: 14px;
margin: 0;
margin-bottom: 4px;
}
@@ -0,0 +1,50 @@
import React from 'react';
import styles from './Banner.css';
import { Icon } from 'coral-ui';
import PropTypes from 'prop-types';
import cn from 'classnames';
function getIcon(icon, error, success) {
if (icon) {
return icon;
}
if (error) {
return 'warning';
}
if (success) {
return 'done';
}
return 'info';
}
const Banner = ({ title, icon, error, success, children }) => (
<section className={styles.root}>
<div
className={cn(styles.leftColumn, {
[styles.error]: error,
[styles.success]: success,
})}
>
<Icon name={getIcon(icon, error, success)} className={styles.icon} />
</div>
<div className={styles.rightColumn}>
<h1 className={styles.title}>{title}</h1>
{children}
</div>
</section>
);
Banner.propTypes = {
title: PropTypes.string,
icon: PropTypes.string,
children: PropTypes.node,
error: PropTypes.bool,
success: PropTypes.bool,
};
Banner.defaultProps = {
title: 'Title',
children: 'Lorem Ipsum Dolot Sit Ahmet',
};
export default Banner;
@@ -0,0 +1,11 @@
.spinner {
display: inline-block;
}
.link {
display: inline-block;
cursor: pointer;
margin: 2px;
color: #2099d6;
border-bottom: 1px solid #2099d6;
}
@@ -0,0 +1,75 @@
import React from 'react';
import Banner from './Banner';
import PropTypes from 'prop-types';
import { Spinner } from 'plugin-api/beta/client/components/ui';
import { t } from 'plugin-api/beta/client/services';
import styles from './EmailVerificationBanner.css';
const EmailVerificationBannerInfo = ({ onResendEmailVerification }) => (
<Banner icon="email" title={t('talk-plugin-notifications.banner_info.title')}>
<p>
{t('talk-plugin-notifications.banner_info.text')}
<a
className={styles.link}
onClick={() => {
onResendEmailVerification();
return false;
}}
>
{t('talk-plugin-notifications.banner_info.verify_now')}
</a>
</p>
</Banner>
);
const EmailVerificationBannerLoading = () => (
<Banner icon="email" title={t('talk-plugin-notifications.banner_info.title')}>
<Spinner className={styles.spinner} />
</Banner>
);
const EmailVerificationBannerError = ({ errorMessage }) => (
<Banner title={t('talk-plugin-notifications.banner_error.title')} error>
<p>{t('talk-plugin-notifications.banner_error.text')}</p>
<p>{errorMessage}</p>
</Banner>
);
const EmailVerificationBannerSuccess = ({ email }) => (
<Banner title={t('talk-plugin-notifications.banner_success.title')} success>
<p>{t('talk-plugin-notifications.banner_success.text', email)}</p>
</Banner>
);
const EmailVerificationBanner = ({
onResendEmailVerification,
email,
success,
loading,
errorMessage,
}) => (
<div>
{success && <EmailVerificationBannerSuccess email={email} />}
{errorMessage && (
<EmailVerificationBannerError errorMessage={errorMessage} />
)}
{loading && <EmailVerificationBannerLoading />}
{!success &&
!errorMessage &&
!loading && (
<EmailVerificationBannerInfo
onResendEmailVerification={onResendEmailVerification}
/>
)}
</div>
);
EmailVerificationBanner.propTypes = {
onResendEmailVerification: PropTypes.func.isRequired,
success: PropTypes.bool.isRequired,
errorMessage: PropTypes.string,
loading: PropTypes.bool.isRequired,
email: PropTypes.string.isRequired,
};
export default EmailVerificationBanner;
@@ -1,5 +1,6 @@
.root {
margin-bottom: 20px;
width: 380px;
}
.innerSettings {
@@ -25,3 +26,7 @@
.notifcationSettingsSlot {
margin-bottom: 3px;
}
.disabled {
color: #e5e5e5;
}
@@ -5,6 +5,8 @@ import { Slot } from 'plugin-api/beta/client/components';
import { t } from 'plugin-api/beta/client/services';
import styles from './Settings.css';
import { BareButton } from 'plugin-api/beta/client/components/ui';
import EmailVerificationBanner from '../containers/EmailVerificationBanner';
import cn from 'classnames';
class Settings extends React.Component {
childFactory = el => {
@@ -23,13 +25,20 @@ class Settings extends React.Component {
updateNotificationSettings,
turnOffAll,
turnOffButtonDisabled,
needEmailVerification,
email,
} = this.props;
return (
<IfSlotIsNotEmpty slot="notificationSettings" queryData={{ root }}>
<div className={styles.root}>
<h3>{t('talk-plugin-notifications.settings_title')}</h3>
<h4 className={styles.subtitle}>
{needEmailVerification && <EmailVerificationBanner email={email} />}
<h4
className={cn(styles.subtitle, {
[styles.disabled]: needEmailVerification,
})}
>
{t('talk-plugin-notifications.settings_subtitle')}
</h4>
<div className={styles.innerSettings}>
@@ -40,6 +49,7 @@ class Settings extends React.Component {
childFactory={this.childFactory}
setTurnOffInputFragment={setTurnOffInputFragment}
updateNotificationSettings={updateNotificationSettings}
disabled={needEmailVerification}
/>
<BareButton
className={styles.turnOffButton}
@@ -63,6 +73,8 @@ Settings.propTypes = {
updateNotificationSettings: PropTypes.func.isRequired,
turnOffAll: PropTypes.func.isRequired,
turnOffButtonDisabled: PropTypes.bool.isRequired,
needEmailVerification: PropTypes.bool.isRequired,
email: PropTypes.string,
};
export default Settings;
@@ -1,6 +1,20 @@
.title {
display: inline-block;
width: 270px;
width: 100%;
cursor: pointer;
user-select: none;
&.disabled {
color: #e5e5e5;
cursor: default;
}
}
.toggle {
display: flex;
align-items: center;
}
.checkBox {
text-align: right;
}
@@ -3,24 +3,36 @@ import PropTypes from 'prop-types';
import { Checkbox } from 'plugin-api/beta/client/components/ui';
import styles from './Toggle.css';
import uuid from 'uuid/v4';
import cn from 'classnames';
class Toggle extends React.Component {
id = uuid();
render() {
const { checked, onChange, children } = this.props;
const { checked, onChange, children, disabled } = this.props;
return (
<div className={styles.toggle}>
<label htmlFor={this.id} className={styles.title}>
<label
htmlFor={this.id}
className={cn(styles.title, { [styles.disabled]: disabled })}
>
{children}
</label>
<Checkbox checked={checked} onChange={onChange} id={this.id} />
<div className={styles.checkBox}>
<Checkbox
checked={checked}
onChange={onChange}
id={this.id}
disabled={disabled}
/>
</div>
</div>
);
}
}
Toggle.propTypes = {
disabled: PropTypes.bool,
checked: PropTypes.bool,
onChange: PropTypes.func,
children: PropTypes.node,
@@ -0,0 +1,35 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withResendEmailConfirmation } from 'plugin-api/beta/client/hocs';
import { compose } from 'recompose';
import EmailVerificationBanner from '../components/EmailVerificationBanner';
class EmailVerificationBannerContainer extends Component {
handleResendEmailVerification = () => {
this.props.resendEmailConfirmation(this.props.email);
};
render() {
return (
<EmailVerificationBanner
onResendEmailVerification={this.handleResendEmailVerification}
errorMessage={this.props.errorMessage}
success={this.props.success}
loading={this.props.loading}
email={this.props.email}
/>
);
}
}
EmailVerificationBannerContainer.propTypes = {
success: PropTypes.bool.isRequired,
loading: PropTypes.bool.isRequired,
resendEmailConfirmation: PropTypes.func.isRequired,
errorMessage: PropTypes.string,
email: PropTypes.string.isRequired,
};
export default compose(withResendEmailConfirmation)(
EmailVerificationBannerContainer
);
@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import Settings from '../components/Settings';
import { withFragments } from 'plugin-api/beta/client/hocs';
import { withFragments, excludeIf } from 'plugin-api/beta/client/hocs';
import { getSlotFragmentSpreads } from 'plugin-api/beta/client/utils';
import { withUpdateNotificationSettings } from '../mutations';
@@ -15,22 +15,30 @@ class SettingsContainer extends React.Component {
};
indicateOn = plugin =>
this.setState({
hasNotifications: this.state.hasNotifications.concat(plugin),
});
this.setState(state => ({
hasNotifications: state.hasNotifications.concat(plugin),
}));
indicateOff = plugin =>
this.setState({
hasNotifications: this.state.hasNotifications.filter(i => i !== plugin),
});
this.setState(state => ({
hasNotifications: state.hasNotifications.filter(i => i !== plugin),
}));
setTurnOffInputFragment = fragment =>
this.setState({
turnOffInput: { ...this.state.turnOffInput, ...fragment },
});
this.setState(state => ({
turnOffInput: { ...state.turnOffInput, ...fragment },
}));
turnOffAll = () => {
this.props.updateNotificationSettings(this.state.turnOffInput);
};
getNeedEmailVerification() {
return !this.props.root.me.profiles.some(
profile => profile.provider === 'local' && profile.confirmedAt
);
}
render() {
return (
<Settings
@@ -42,6 +50,8 @@ class SettingsContainer extends React.Component {
updateNotificationSettings={this.props.updateNotificationSettings}
turnOffAll={this.turnOffAll}
turnOffButtonDisabled={this.state.hasNotifications.length === 0}
needEmailVerification={this.getNeedEmailVerification()}
email={this.props.root.me.email}
/>
);
}
@@ -59,9 +69,22 @@ const enhance = compose(
fragment TalkNotifications_Settings_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
me {
email
profiles {
provider
... on LocalUserProfile {
confirmedAt
}
}
}
}
`,
}),
excludeIf(
props =>
!props.root.me.profiles.some(profile => profile.provider === 'local')
),
withUpdateNotificationSettings
);
@@ -3,3 +3,13 @@ en:
settings_title: Notifications
settings_subtitle: Receive notifications when
turn_off_all: I do not want to receive notifications
banner_info:
title: Email Verification Required
text: To receive email notifications you must have a verified email address.
verify_now: Verify your email now
banner_success:
title: Email Verification Sent
text: An email has been sent to {0} containing a verification link.
banner_error:
title: Error
text: There has been an error sending your verification email. Please try again later.
@@ -1,7 +1,123 @@
const { groupBy, forEach } = require('lodash');
const { get, find, groupBy, forEach, property } = require('lodash');
const debug = require('debug')('talk-plugin-notifications');
const uuid = require('uuid/v4');
const { UNSUBSCRIBE_SUBJECT } = require('./config');
const {
UNSUBSCRIBE_SUBJECT,
DISABLE_REQUIRE_EMAIL_VERIFICATIONS,
} = require('./config');
// handleHandlers will call the handle method on each handler to determine if a
// notification should be sent for it.
const handleHandlers = (ctx, handlers, ...args) =>
Promise.all(
handlers.map(async handler => {
// Grab the handler reference.
const { handle, category, event } = handler;
try {
// Attempt to create a notification out of it.
const notification = await handle(ctx, ...args);
if (!notification) {
ctx.log.info('no notification deemed by event handler');
return;
}
// Send the notification back.
ctx.log.info({ category, event }, 'notification detected for event');
return { handler, notification };
} catch (err) {
ctx.log.error({ err }, 'could not handle the event');
return;
}
})
);
// filterSuperseded will filter all the possible notifications and only send
// those notifications that are not superseded by another type of notification.
const filterSuperseded = (
{ handler: { category }, notification: { userID: destinationUserID } },
index,
notifications
) =>
!notifications.some(
({
handler: { supersedesCategories = [] },
notification: { userID: notificationUserID },
}) =>
// Only allow notifications to supersede another notification if that
// notification is also destined for the same user.
notificationUserID === destinationUserID &&
// If another notification that is destined for the same user also exists
// and declares that it supersedes this one, return true so we can filter
// this one from the list.
supersedesCategories.some(
supersededCategory => supersededCategory === category
)
);
const USER_CONFIRMATION_QUERY = `
query CheckUserConfirmation($userID: ID!) {
user(id: $userID) {
profiles {
provider
... on LocalUserProfile {
confirmedAt
}
}
}
}
`;
// filterVerifiedNotification checks to see if a user has a verified email
// address, and if they do, returns the notification payload again, otherwise,
// returns undefined.
const filterVerifiedNotification = ctx => async notification => {
// Grab the user that we're supposed to be sending the notification to.
const { notification: { userID } } = notification;
// Check their confirmed status. This should have already been hit by the
// loaders, so we shouldn't make any more database requests.
const { errors, data } = await ctx.graphql(USER_CONFIRMATION_QUERY, {
userID,
});
if (errors) {
ctx.log.error(
{ err: errors },
'could not query for user confirmation status'
);
return;
}
// Get the first local profile from the user.
const profile = find(get(data, 'user.profiles', []), ['provider', 'local']);
if (!profile) {
ctx.log.warn({ user_id: userID }, 'user did not have a local profile');
return;
}
// Pull out the confirmed status from the profile.
const confirmed = get(profile, 'confirmedAt', null) !== null;
if (!confirmed) {
ctx.log.info(
{ user_id: userID },
'user did not have their local profile confirmed, but had settings enabled, not mailing'
);
return;
}
return notification;
};
// filterVerified performs filtering in a complicated way because we can't use
// Promise.all on a Array.prototype.filter call.
const filterVerified = async (ctx, notifications) => {
notifications = await Promise.all(
notifications.map(filterVerifiedNotification(ctx))
);
// This acts as a poor-mans identity filter to remove all falsy values.
return notifications.filter(property('notification'));
};
class NotificationManager {
constructor(context) {
@@ -44,33 +160,34 @@ class NotificationManager {
* @param {Object} handler a notification handler
*/
handle(handlers) {
return async (...args) =>
Promise.all(
handlers.map(async handler => {
// Grab the handler reference.
const { handle } = handler;
return async (...args) => {
// Create a system context to send down.
const ctx = this.context.forSystem();
// Create a system context to send down.
const ctx = this.context.forSystem();
// Get all the notifications to load.
let notifications = await handleHandlers(ctx, handlers, ...args);
try {
// Attempt to create a notification out of it.
const notification = await handle(ctx, ...args);
if (!notification) {
return;
}
// Only let handlers past that have a notification to send.
notifications = notifications.filter(property('notification'));
// Extract the notification details.
const { userID, date, context } = notification;
// Check to see if some of the other notifications that are queued
// had this notification superseded.
notifications = notifications.filter(filterSuperseded);
// Send the notification.
return this.send(ctx, userID, date, handler, context);
} catch (err) {
ctx.log.error({ err }, 'could not handle the event');
return;
}
})
// Only let notifications through for users who have their email addresses
// verified if we are configured to do so.
if (!DISABLE_REQUIRE_EMAIL_VERIFICATIONS) {
notifications = await filterVerified(ctx, notifications);
}
// Send the remaining notifications.
return Promise.all(
notifications.map(
({ handler, notification: { userID, date, context } }) =>
this.send(ctx, userID, date, handler, context)
)
);
};
}
async send(ctx, userID, date, handler, context) {
@@ -90,7 +207,7 @@ class NotificationManager {
'organizationName'
);
if (organizationName === null) {
ctx.log.debug(
ctx.log.error(
'could not send the notification, organization name not in settings'
);
return;
@@ -123,7 +240,7 @@ class NotificationManager {
user: userID,
});
ctx.log.debug(`Sent the notification for Job.ID[${task.id}]`);
ctx.log.info(`Sent the notification for Job.ID[${task.id}]`);
} catch (err) {
ctx.log.error(
{ err, message: err.message },
@@ -142,10 +259,10 @@ class NotificationManager {
*/
async getBody(ctx, handler, context) {
const { connectors: { services: { I18n: { t } } } } = ctx;
const { category } = handler;
const { category, hydrate = () => [] } = handler;
// Get the body replacement variables for the translation key.
const replacements = await handler.hydrate(ctx, category, context);
const replacements = await hydrate(ctx, category, context);
// Generate the body.
return t(
@@ -1,3 +1,8 @@
module.exports = {
UNSUBSCRIBE_SUBJECT: 'nunsub',
// TODO: replace this with a config option in the plugin config when we get there..
DISABLE_REQUIRE_EMAIL_VERIFICATIONS:
process.env.TALK_DISABLE_REQUIRE_EMAIL_VERIFICATIONS_NOTIFICATIONS ===
'TRUE',
};
@@ -1,4 +1,5 @@
const { get } = require('lodash');
const { DISABLE_REQUIRE_EMAIL_VERIFICATIONS } = require('./config');
module.exports = {
User: {
@@ -16,4 +17,8 @@ module.exports = {
await User.updateNotificationSettings(input);
},
},
Settings: {
notificationsRequireConfirmation: () =>
Boolean(!DISABLE_REQUIRE_EMAIL_VERIFICATIONS),
},
};
@@ -5,6 +5,13 @@ type User {
notificationSettings: NotificationSettings
}
type Settings {
# notificationsRequireConfirmation when true indicates that User's must have
# their email address confirmed/verified before they can receive
# notifications.
notificationsRequireConfirmation: Boolean
}
type UpdateNotificationSettingsResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]

Some files were not shown because too many files have changed in this diff Show More