mirror of
https://github.com/wassname/talk.git
synced 2026-07-23 13:10:20 +08:00
Fix merge conflict
This commit is contained in:
+21
-6
@@ -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
@@ -2,4 +2,4 @@
|
||||
dist
|
||||
node_modules
|
||||
public
|
||||
**/*.min.js
|
||||
**/*.min.js
|
||||
|
||||
@@ -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
|
||||
-->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
# Talk · [](https://circleci.com/gh/coralproject/talk) · [](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) · [](CONTRIBUTING.md#pull-requests)
|
||||
# Talk · [](https://circleci.com/gh/coralproject/talk) · [](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946) · [](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}>
|
||||
|
||||
@@ -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>
|
||||
<span className={styles.editedMarker}>
|
||||
({t('comment.edited')})
|
||||
</span>
|
||||
{comment.editing && comment.editing.edited ? (
|
||||
<span>
|
||||
<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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -50,6 +50,11 @@
|
||||
color: #00a291;
|
||||
}
|
||||
|
||||
.input:disabled + .checkbox:before {
|
||||
color: #e5e5e5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.input:focus + .checkbox:before {
|
||||
color: #00a291;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const { property } = require('lodash');
|
||||
|
||||
const LocalUserProfile = {
|
||||
confirmedAt: property('metadata.confirmed_at'),
|
||||
};
|
||||
|
||||
module.exports = LocalUserProfile;
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const UserProfile = {
|
||||
__resolveType({ provider }) {
|
||||
switch (provider) {
|
||||
case 'local':
|
||||
return 'LocalUserProfile';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = UserProfile;
|
||||
+35
-1
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user