diff --git a/.gitignore b/.gitignore
index a6630d22e..5efed2e0c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,6 +43,8 @@ plugins/*
!plugins/talk-plugin-notifications-category-featured
!plugins/talk-plugin-notifications-category-reply
!plugins/talk-plugin-notifications-category-staff
+!plugins/talk-plugin-notifications-digest-daily
+!plugins/talk-plugin-notifications-digest-hourly
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
!plugins/talk-plugin-profile-settings
diff --git a/client/coral-admin/src/routes/Community/components/People.css b/client/coral-admin/src/routes/Community/components/People.css
index c4561ace1..669da480d 100644
--- a/client/coral-admin/src/routes/Community/components/People.css
+++ b/client/coral-admin/src/routes/Community/components/People.css
@@ -130,3 +130,12 @@ th.header:nth-child(2), th.header:nth-child(3) {
.loadMore {
margin-top: 24px;
}
+
+.roleDropdown {
+ width: 150px;
+}
+
+.roleOption {
+ min-width: 100px;
+}
+
diff --git a/client/coral-admin/src/routes/Community/components/People.js b/client/coral-admin/src/routes/Community/components/People.js
index 1f5e4bab8..5645f88f1 100644
--- a/client/coral-admin/src/routes/Community/components/People.js
+++ b/client/coral-admin/src/routes/Community/components/People.js
@@ -200,24 +200,31 @@ class People extends React.Component {
setUserRole(user.id, role)}
>
diff --git a/client/coral-admin/src/routes/Stories/components/Stories.css b/client/coral-admin/src/routes/Stories/components/Stories.css
index 63a05ab6a..28f67151d 100644
--- a/client/coral-admin/src/routes/Stories/components/Stories.css
+++ b/client/coral-admin/src/routes/Stories/components/Stories.css
@@ -89,3 +89,12 @@
display: inline-block;
}
}
+
+.statusDropdown {
+ width: 150px;
+}
+
+.statusDropdownOption {
+ min-width: 100px;
+}
+
diff --git a/client/coral-admin/src/routes/Stories/components/Stories.js b/client/coral-admin/src/routes/Stories/components/Stories.js
index f94685fe8..ad2c7c5fc 100644
--- a/client/coral-admin/src/routes/Stories/components/Stories.js
+++ b/client/coral-admin/src/routes/Stories/components/Stories.js
@@ -22,11 +22,20 @@ class Stories extends Component {
const closed = !!(closedAt && new Date(closedAt).getTime() < Date.now());
return (
this.props.onStatusChange(value, id)}
>
-
-
+
+
);
};
diff --git a/client/coral-embed-stream/src/tabs/profile/components/BlankCommentHistory.css b/client/coral-embed-stream/src/tabs/profile/components/BlankCommentHistory.css
new file mode 100644
index 000000000..821396b73
--- /dev/null
+++ b/client/coral-embed-stream/src/tabs/profile/components/BlankCommentHistory.css
@@ -0,0 +1,20 @@
+.root {
+ text-align: center;
+ padding-top: 24px;
+}
+
+.icon {
+ font-size: 64px;
+ color: #3a4a52;
+}
+
+.title {
+ font-size: 18px;
+ margin-bottom: 8px;
+}
+
+.info {
+ margin-top: 0px;
+ font-size: 14px;
+ color: #7d8285;
+}
diff --git a/client/coral-embed-stream/src/tabs/profile/components/BlankCommentHistory.js b/client/coral-embed-stream/src/tabs/profile/components/BlankCommentHistory.js
new file mode 100644
index 000000000..1a193ef5c
--- /dev/null
+++ b/client/coral-embed-stream/src/tabs/profile/components/BlankCommentHistory.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import styles from './BlankCommentHistory.css';
+import { Icon } from 'coral-ui';
+import cn from 'classnames';
+
+import t from 'coral-framework/services/i18n';
+
+export default () => (
+
+
+ {t('comment_history_blank.title')}
+ {t('comment_history_blank.info')}
+
+);
diff --git a/client/coral-embed-stream/src/tabs/profile/components/CommentHistory.js b/client/coral-embed-stream/src/tabs/profile/components/CommentHistory.js
index 39f3bfd04..64d9e97ae 100644
--- a/client/coral-embed-stream/src/tabs/profile/components/CommentHistory.js
+++ b/client/coral-embed-stream/src/tabs/profile/components/CommentHistory.js
@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import Comment from '../containers/Comment';
import LoadMore from './LoadMore';
+import BlankCommentHistory from './BlankCommentHistory';
class CommentHistory extends React.Component {
state = {
@@ -22,6 +23,9 @@ class CommentHistory extends React.Component {
render() {
const { navigate, comments, data, root } = this.props;
+ if (!comments.nodes.length) {
+ return ;
+ }
return (
diff --git a/client/coral-framework/components/TalkProvider.js b/client/coral-framework/components/TalkProvider.js
index 343d559ea..9caba7fb8 100644
--- a/client/coral-framework/components/TalkProvider.js
+++ b/client/coral-framework/components/TalkProvider.js
@@ -10,6 +10,7 @@ class TalkProvider extends React.Component {
plugins: this.props.plugins,
rest: this.props.rest,
graphql: this.props.graphql,
+ introspection: this.props.introspection,
notification: this.props.notification,
localStorage: this.props.localStorage,
sessionStorage: this.props.sessionStorage,
@@ -29,6 +30,7 @@ class TalkProvider extends React.Component {
TalkProvider.childContextTypes = {
pym: PropTypes.object,
+ introspection: PropTypes.object,
eventEmitter: PropTypes.object,
plugins: PropTypes.object,
rest: PropTypes.func,
diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js
index 5e2e3dd40..7ed114b73 100644
--- a/client/coral-framework/hocs/index.js
+++ b/client/coral-framework/hocs/index.js
@@ -11,6 +11,7 @@ export { default as withSignUp } from './withSignUp';
export { default as withForgotPassword } from './withForgotPassword';
export { default as withSetUsername } from './withSetUsername';
export { default as withPopupAuthHandler } from './withPopupAuthHandler';
+export { default as withEnumValues } from './withEnumValues';
export {
default as withResendEmailConfirmation,
} from './withResendEmailConfirmation';
diff --git a/client/coral-framework/hocs/withEnumValues.js b/client/coral-framework/hocs/withEnumValues.js
new file mode 100644
index 000000000..ea5cef922
--- /dev/null
+++ b/client/coral-framework/hocs/withEnumValues.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import hoistStatics from 'recompose/hoistStatics';
+import PropTypes from 'prop-types';
+
+/**
+ * WithEnumValues inject given property name for given enum.
+ */
+export default (enumName, propName) =>
+ hoistStatics(WrappedComponent => {
+ class WithEnumValues extends React.Component {
+ static contextTypes = {
+ introspection: PropTypes.object,
+ };
+
+ render() {
+ const inject = {
+ [propName]: this.context.introspection.getEnumValues(enumName),
+ };
+ return ;
+ }
+ }
+
+ return WithEnumValues;
+ });
diff --git a/client/coral-framework/services/introspection.js b/client/coral-framework/services/introspection.js
index 8e3160aa7..e4636a339 100644
--- a/client/coral-framework/services/introspection.js
+++ b/client/coral-framework/services/introspection.js
@@ -19,6 +19,16 @@ class Introspection {
isValidEnumValue(name, value) {
return this._enums[name] && this._enums[name].indexOf(value) >= 0;
}
+
+ /**
+ * getEnumValues returns array of possible values.
+ * @param {string} name
+ * @param {string} value
+ * @return {array}
+ */
+ getEnumValues(name) {
+ return this._enums[name];
+ }
}
/**
diff --git a/client/coral-ui/components/Dropdown.css b/client/coral-ui/components/Dropdown.css
index 794955220..c17e6322a 100644
--- a/client/coral-ui/components/Dropdown.css
+++ b/client/coral-ui/components/Dropdown.css
@@ -1,20 +1,27 @@
.dropdown {
position: relative;
- width: 150px;
height: 34px;
background: #2c2c2c;
box-sizing: border-box;
color: white;
border-radius: 3px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
+ line-height: 20px;
font-size: 0.98em;
border-radius: 3px;
+ cursor: pointer;
+
+ &.disabled {
+ color: #e5e5e5;
+ background: #888;
+ cursor: default;
+ pointer-events: none;
+ }
}
.toggle {
- padding: 8px 15px;
- cursor: pointer;
+ padding: 8px 45px 8px 15px;
outline: none;
&:focus {
@@ -27,7 +34,6 @@
}
.label {
- text-transform: capitalize;
}
.list {
@@ -38,7 +44,6 @@
border-radius: 2px;
background: white;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
- width: 100%;
list-style: none;
padding: 0;
margin: 0;
diff --git a/client/coral-ui/components/Dropdown.js b/client/coral-ui/components/Dropdown.js
index fe852d265..bf885477f 100644
--- a/client/coral-ui/components/Dropdown.js
+++ b/client/coral-ui/components/Dropdown.js
@@ -13,6 +13,12 @@ class Dropdown extends React.Component {
isOpen: false,
};
+ componentWillReceiveProps(nextProps) {
+ if (this.state.isOpen && nextProps.disabled) {
+ this.toggle();
+ }
+ }
+
componentDidUpdate(_, prevState) {
if (!this.state.isOpen && prevState.isOpen) {
// Refocus on the toggle element when menu closes.
@@ -69,6 +75,10 @@ class Dropdown extends React.Component {
};
toggle = () => {
+ if (this.props.disabled) {
+ return false;
+ }
+
this.setState({
isOpen: !this.state.isOpen,
});
@@ -135,11 +145,21 @@ class Dropdown extends React.Component {
containerClassName,
toggleClassName,
toggleOpenClassName,
+ disabled,
+ className,
} = this.props;
return (
{this.props.icon && (
@@ -206,6 +226,7 @@ class Dropdown extends React.Component {
}
Dropdown.propTypes = {
+ className: PropTypes.string,
containerClassName: PropTypes.string,
toggleClassName: PropTypes.string,
toggleOpenClassName: PropTypes.string,
@@ -213,6 +234,7 @@ Dropdown.propTypes = {
icon: PropTypes.string,
onChange: PropTypes.func.isRequired,
children: PropTypes.node.isRequired,
+ disabled: PropTypes.bool,
value: PropTypes.oneOfType([
PropTypes.number,
PropTypes.string,
diff --git a/client/coral-ui/components/Option.css b/client/coral-ui/components/Option.css
index 1e7627681..892bc4e7c 100644
--- a/client/coral-ui/components/Option.css
+++ b/client/coral-ui/components/Option.css
@@ -1,7 +1,7 @@
.option {
padding: 10px;
- text-transform: capitalize;
outline: none;
+ white-space: nowrap;
&:focus, &:hover {
background-color: #ccc;
diff --git a/graph/context.js b/graph/context.js
index adf5be06a..52bb7fdfc 100644
--- a/graph/context.js
+++ b/graph/context.js
@@ -52,7 +52,7 @@ class Context {
this.id = ctx.id || uuid.v4();
// Attach a logger or create one.
- this.log = ctx.log || createLogger('context', this.id);
+ this.log = ctx.log || createLogger('graph:context', this.id);
// Load the current logged in user to `user`, otherwise this will be null.
this.user = get(ctx, 'user');
diff --git a/graph/loaders/assets.js b/graph/loaders/assets.js
index 554ee527d..2f0595e81 100644
--- a/graph/loaders/assets.js
+++ b/graph/loaders/assets.js
@@ -119,7 +119,7 @@ const findOrCreateAssetByURL = async (ctx, url) => {
// If this is a new asset, then we need to scrape it!
if (!asset.scraped) {
// Create the Scraper job.
- await Scraper.create(asset);
+ await Scraper.create(ctx, asset.id);
}
return asset;
diff --git a/graph/mutators/asset.js b/graph/mutators/asset.js
index 3d018658e..2997e7cd4 100644
--- a/graph/mutators/asset.js
+++ b/graph/mutators/asset.js
@@ -63,9 +63,9 @@ const closeNow = async (ctx, id) =>
* @param {String} id the asset's id to scrape
*/
const scrapeAsset = async (ctx, id) => {
- const { services: { Scraper } } = ctx;
+ const { connectors: { services: { Scraper } } } = ctx;
- return Scraper.create({ id });
+ return Scraper.create(ctx, id);
};
module.exports = ctx => {
diff --git a/jobs/index.js b/jobs/index.js
index b64e59362..9093090ad 100644
--- a/jobs/index.js
+++ b/jobs/index.js
@@ -1,4 +1,4 @@
-const jobs = [require('./mailer')];
+const jobs = [require('./mailer'), require('./scraper')];
const process = () => jobs.forEach(job => job());
diff --git a/jobs/scraper.js b/jobs/scraper.js
index 424304ba8..2e3fa0927 100644
--- a/jobs/scraper.js
+++ b/jobs/scraper.js
@@ -1,7 +1,8 @@
const Asset = require('../models/asset');
const scraper = require('../services/scraper');
const Assets = require('../services/assets');
-const debug = require('debug')('talk:jobs:scraper');
+const { createLogger } = require('../services/logging');
+const logger = createLogger('jobs:scraper');
const metascraper = require('metascraper');
/**
@@ -39,38 +40,34 @@ function update(id, meta) {
}
module.exports = () => {
- debug(`Now processing ${scraper.task.name} jobs`);
+ logger.info({ taskName: scraper.task.name }, 'Now processing jobs');
scraper.task.process(async (job, done) => {
- debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`);
+ const { id, asset_id } = job.data;
+
+ const log = logger.child({ traceID: id, jobID: job.id, assetID: asset_id });
+ log.info('Starting scrape');
try {
// Find the asset, or complain that it doesn't exist.
const asset = await Assets.findById(job.data.asset_id);
if (!asset) {
- return done(new Error('asset not found'));
+ throw new Error('asset not found');
}
// Scrape the metadata from the asset.
const meta = await scrape(asset);
- debug(
- `Scraped ${JSON.stringify(meta)} on Job[${job.id}] for Asset[${
- job.data.asset_id
- }]`
- );
+ log.info('Finished scraping');
// Assign the metadata retrieved for the asset to the db.
await update(job.data.asset_id, meta);
} catch (err) {
- debug(
- `Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`,
- err
- );
+ log.error({ err }, 'Failed to scrape');
return done(err);
}
- debug(`Finished on Job[${job.id}] for Asset[${job.data.asset_id}]`);
+ log.info('Finished updating');
done();
});
};
diff --git a/locales/en.yml b/locales/en.yml
index 2bbe23254..d540e8c1e 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -358,6 +358,9 @@ en:
report_notif: "Thank you for reporting this comment. Our moderation team has been notified and will review it shortly."
report_notif_remove: "Your report has been removed."
reported: Reported
+ comment_history_blank:
+ title: You have not written any comments
+ info: A history of your comments will appear here
settings:
from_settings_page: "From the Profile Page you can see your comment history."
my_comment_history: "My comment History"
@@ -474,4 +477,4 @@ en:
close: "Close this Installer"
admin_sidebar:
view_options: "View Options"
- sort_comments: "Sort Comments"
\ No newline at end of file
+ sort_comments: "Sort Comments"
diff --git a/plugin-api/beta/client/hocs/index.js b/plugin-api/beta/client/hocs/index.js
index 8b91c83de..35899a418 100644
--- a/plugin-api/beta/client/hocs/index.js
+++ b/plugin-api/beta/client/hocs/index.js
@@ -12,6 +12,7 @@ export {
withSignUp,
withResendEmailConfirmation,
withSetUsername,
+ withEnumValues,
} from 'coral-framework/hocs';
export {
withIgnoreUser,
diff --git a/plugins/talk-plugin-ignore-user/client/components/IgnoredUserSection.css b/plugins/talk-plugin-ignore-user/client/components/IgnoredUserSection.css
index b279101ea..898290094 100644
--- a/plugins/talk-plugin-ignore-user/client/components/IgnoredUserSection.css
+++ b/plugins/talk-plugin-ignore-user/client/components/IgnoredUserSection.css
@@ -20,3 +20,7 @@
color: #D0011B;
text-decoration: underline;
}
+
+.blank {
+ color: #7d8285;
+}
diff --git a/plugins/talk-plugin-ignore-user/client/components/IgnoredUserSection.js b/plugins/talk-plugin-ignore-user/client/components/IgnoredUserSection.js
index d199eb635..b8ada5328 100644
--- a/plugins/talk-plugin-ignore-user/client/components/IgnoredUserSection.js
+++ b/plugins/talk-plugin-ignore-user/client/components/IgnoredUserSection.js
@@ -1,23 +1,38 @@
import React from 'react';
+import PropTypes from 'prop-types';
import styles from './IgnoredUserSection.css';
import { t } from 'plugin-api/beta/client/services';
-export default ({ ignoredUsers, stopIgnoringUser }) => (
+const IgnoreUserSection = ({ ignoredUsers, stopIgnoringUser }) => (
{t('talk-plugin-ignore-user.section_title')}
- {t('talk-plugin-ignore-user.section_info')}
-
- {ignoredUsers.map(({ username, id }) => (
- -
- {username}
-
-
- ))}
-
+ {!ignoredUsers.length && (
+ {t('talk-plugin-ignore-user.blank_info')}
+ )}
+ {!!ignoredUsers.length && (
+
+ {t('talk-plugin-ignore-user.section_info')}
+
+ {ignoredUsers.map(({ username, id }) => (
+ -
+ {username}
+
+
+ ))}
+
+
+ )}
);
+
+IgnoreUserSection.propTypes = {
+ ignoredUsers: PropTypes.array.isRequired,
+ stopIgnoringUser: PropTypes.func.isRequired,
+};
+
+export default IgnoreUserSection;
diff --git a/plugins/talk-plugin-ignore-user/client/containers/IgnoredUserSection.js b/plugins/talk-plugin-ignore-user/client/containers/IgnoredUserSection.js
index ae402286c..36bb477c4 100644
--- a/plugins/talk-plugin-ignore-user/client/containers/IgnoredUserSection.js
+++ b/plugins/talk-plugin-ignore-user/client/containers/IgnoredUserSection.js
@@ -1,9 +1,9 @@
import React from 'react';
+import PropTypes from 'prop-types';
import IgnoredUserSection from '../components/IgnoredUserSection';
import { compose, gql } from 'react-apollo';
import {
withFragments,
- excludeIf,
withStopIgnoringUser,
} from 'plugin-api/beta/client/hocs';
@@ -18,6 +18,11 @@ class IgnoredUserSectionContainer extends React.Component {
}
}
+IgnoredUserSectionContainer.propTypes = {
+ stopIgnoringUser: PropTypes.func.isRequired,
+ root: PropTypes.object.isRequired,
+};
+
const withIgnoredUserSectionFragments = withFragments({
root: gql`
fragment TalkIgnoreUser_IgnoredUserSection_root on RootQuery {
@@ -32,10 +37,6 @@ const withIgnoredUserSectionFragments = withFragments({
`,
});
-const enhance = compose(
- withIgnoredUserSectionFragments,
- withStopIgnoringUser,
- excludeIf(({ root: { me } }) => me.ignoredUsers.length === 0)
-);
+const enhance = compose(withIgnoredUserSectionFragments, withStopIgnoringUser);
export default enhance(IgnoredUserSectionContainer);
diff --git a/plugins/talk-plugin-ignore-user/client/translations.yml b/plugins/talk-plugin-ignore-user/client/translations.yml
index 4f32c535d..33e5b10ed 100644
--- a/plugins/talk-plugin-ignore-user/client/translations.yml
+++ b/plugins/talk-plugin-ignore-user/client/translations.yml
@@ -13,6 +13,7 @@ da:
confirmation_title: Ignore {0}?
en:
talk-plugin-ignore-user:
+ blank_info: You are currently not ignoring any users
section_title: Ignored users
section_info: Because you ignored the following commenters, their comments are hidden.
stop_ignoring: Stop ignoring
@@ -68,7 +69,7 @@ nl_NL:
ignore_user: Gebruiker negeren
cancel: Annuleren
confirmation: |
- Wanneer je een gebruiker negeert, zullen al zijn/haar reacties voor jou worden verborgen. Je kunt
+ Wanneer je een gebruiker negeert, zullen al zijn/haar reacties voor jou worden verborgen. Je kunt
dit later ongedaan maken vanuit Mijn profiel.
notify_success: |
Je negeert nu {0}. Je kunt dit ongedaan maken vanuit Mijn profiel.
diff --git a/plugins/talk-plugin-notifications-category-featured/index.js b/plugins/talk-plugin-notifications-category-featured/index.js
index c6b0ea2bb..bd90db3ea 100644
--- a/plugins/talk-plugin-notifications-category-featured/index.js
+++ b/plugins/talk-plugin-notifications-category-featured/index.js
@@ -84,6 +84,7 @@ const handler = {
category: 'featured',
event: 'commentFeatured',
hydrate,
+ digestOrder: 10,
};
module.exports = {
diff --git a/plugins/talk-plugin-notifications-category-featured/translations.yml b/plugins/talk-plugin-notifications-category-featured/translations.yml
index bd0d7412f..18b00ea71 100644
--- a/plugins/talk-plugin-notifications-category-featured/translations.yml
+++ b/plugins/talk-plugin-notifications-category-featured/translations.yml
@@ -2,5 +2,5 @@ en:
talk-plugin-notifications:
categories:
featured:
- subject: "One of your comments was featured on [{0}]"
+ 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}"
\ No newline at end of file
diff --git a/plugins/talk-plugin-notifications-category-reply/index.js b/plugins/talk-plugin-notifications-category-reply/index.js
index 16cfbfb72..0b6fe62a0 100644
--- a/plugins/talk-plugin-notifications-category-reply/index.js
+++ b/plugins/talk-plugin-notifications-category-reply/index.js
@@ -90,7 +90,13 @@ const hydrate = async (ctx, category, context) => {
return [headline, replier, permalink];
};
-const handler = { handle, category: 'reply', event: 'commentAdded', hydrate };
+const handler = {
+ handle,
+ category: 'reply',
+ event: 'commentAdded',
+ hydrate,
+ digestOrder: 30,
+};
module.exports = {
typeDefs: `
diff --git a/plugins/talk-plugin-notifications-category-staff/index.js b/plugins/talk-plugin-notifications-category-staff/index.js
index d229456f7..a5d12c175 100644
--- a/plugins/talk-plugin-notifications-category-staff/index.js
+++ b/plugins/talk-plugin-notifications-category-staff/index.js
@@ -117,6 +117,7 @@ const handler = {
event: 'commentAdded',
hydrate,
supersedesCategories: ['reply'],
+ digestOrder: 20,
};
module.exports = {
diff --git a/plugins/talk-plugin-notifications-digest-daily/client/.eslintrc.json b/plugins/talk-plugin-notifications-digest-daily/client/.eslintrc.json
new file mode 100644
index 000000000..c8a6db18a
--- /dev/null
+++ b/plugins/talk-plugin-notifications-digest-daily/client/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": "@coralproject/eslint-config-talk/client"
+}
diff --git a/plugins/talk-plugin-notifications-digest-daily/client/index.js b/plugins/talk-plugin-notifications-digest-daily/client/index.js
new file mode 100644
index 000000000..3ef3337ee
--- /dev/null
+++ b/plugins/talk-plugin-notifications-digest-daily/client/index.js
@@ -0,0 +1,7 @@
+import translations from './translations.yml';
+
+export default {
+ // The only thing necessary on the client side is to provide
+ // a translation for the new enum.
+ translations,
+};
diff --git a/plugins/talk-plugin-notifications-digest-daily/client/translations.yml b/plugins/talk-plugin-notifications-digest-daily/client/translations.yml
new file mode 100644
index 000000000..1744918ec
--- /dev/null
+++ b/plugins/talk-plugin-notifications-digest-daily/client/translations.yml
@@ -0,0 +1,4 @@
+en:
+ talk-plugin-notifications:
+ digest_enum:
+ DAILY: In a daily digest
diff --git a/plugins/talk-plugin-notifications-digest-daily/index.js b/plugins/talk-plugin-notifications-digest-daily/index.js
new file mode 100644
index 000000000..a06558e33
--- /dev/null
+++ b/plugins/talk-plugin-notifications-digest-daily/index.js
@@ -0,0 +1,11 @@
+module.exports = {
+ typeDefs: `
+ enum DIGEST_FREQUENCY {
+ # DAILY will queue up the notifications and send them daily.
+ DAILY
+ }
+ `,
+ notificationDigests: {
+ DAILY: { cronTime: '0 0 * * *', timeZone: 'America/New_York' },
+ },
+};
diff --git a/plugins/talk-plugin-notifications-digest-hourly/client/.eslintrc.json b/plugins/talk-plugin-notifications-digest-hourly/client/.eslintrc.json
new file mode 100644
index 000000000..c8a6db18a
--- /dev/null
+++ b/plugins/talk-plugin-notifications-digest-hourly/client/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": "@coralproject/eslint-config-talk/client"
+}
diff --git a/plugins/talk-plugin-notifications-digest-hourly/client/index.js b/plugins/talk-plugin-notifications-digest-hourly/client/index.js
new file mode 100644
index 000000000..3ef3337ee
--- /dev/null
+++ b/plugins/talk-plugin-notifications-digest-hourly/client/index.js
@@ -0,0 +1,7 @@
+import translations from './translations.yml';
+
+export default {
+ // The only thing necessary on the client side is to provide
+ // a translation for the new enum.
+ translations,
+};
diff --git a/plugins/talk-plugin-notifications-digest-hourly/client/translations.yml b/plugins/talk-plugin-notifications-digest-hourly/client/translations.yml
new file mode 100644
index 000000000..da4bad14f
--- /dev/null
+++ b/plugins/talk-plugin-notifications-digest-hourly/client/translations.yml
@@ -0,0 +1,4 @@
+en:
+ talk-plugin-notifications:
+ digest_enum:
+ HOURLY: In an hourly digest
diff --git a/plugins/talk-plugin-notifications-digest-hourly/index.js b/plugins/talk-plugin-notifications-digest-hourly/index.js
new file mode 100644
index 000000000..50c53faf6
--- /dev/null
+++ b/plugins/talk-plugin-notifications-digest-hourly/index.js
@@ -0,0 +1,11 @@
+module.exports = {
+ typeDefs: `
+ enum DIGEST_FREQUENCY {
+ # HOURLY will queue up the notifications and send them hourly.
+ HOURLY
+ }
+ `,
+ notificationDigests: {
+ HOURLY: { cronTime: '0 * * * *', timeZone: 'America/New_York' },
+ },
+};
diff --git a/plugins/talk-plugin-notifications/client/components/Settings.css b/plugins/talk-plugin-notifications/client/components/Settings.css
index c4d43fd9a..f9a65c88d 100644
--- a/plugins/talk-plugin-notifications/client/components/Settings.css
+++ b/plugins/talk-plugin-notifications/client/components/Settings.css
@@ -1,6 +1,6 @@
.root {
margin-bottom: 20px;
- width: 380px;
+ width: 350px;
}
.innerSettings {
@@ -30,3 +30,16 @@
.disabled {
color: #e5e5e5;
}
+
+.digest {
+ display: flex;
+ align-items: center;
+}
+
+.titleDigest {
+ width: 100%;
+}
+
+.digestDropDown {
+ flex-shrink: 0;
+}
diff --git a/plugins/talk-plugin-notifications/client/components/Settings.js b/plugins/talk-plugin-notifications/client/components/Settings.js
index 9fd16bc0d..f71affcbb 100644
--- a/plugins/talk-plugin-notifications/client/components/Settings.js
+++ b/plugins/talk-plugin-notifications/client/components/Settings.js
@@ -4,7 +4,11 @@ import { IfSlotIsNotEmpty } from 'plugin-api/beta/client/components';
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 {
+ BareButton,
+ Dropdown,
+ Option,
+} from 'plugin-api/beta/client/components/ui';
import EmailVerificationBanner from '../containers/EmailVerificationBanner';
import cn from 'classnames';
@@ -24,9 +28,13 @@ class Settings extends React.Component {
setTurnOffInputFragment,
updateNotificationSettings,
turnOffAll,
- turnOffButtonDisabled,
needEmailVerification,
email,
+ digestFrequencyValues,
+ digestFrequency,
+ disableDigest,
+ disableTurnoffButton,
+ onChangeDigestFrequency,
} = this.props;
const slotPassthrough = {
@@ -37,10 +45,15 @@ class Settings extends React.Component {
};
return (
-
+
{t('talk-plugin-notifications.settings_title')}
- {needEmailVerification && }
+
+ {needEmailVerification && }
+
-
- {t('talk-plugin-notifications.turn_off_all')}
-
+ {digestFrequencyValues.length > 1 && (
+
+
+ {t('talk-plugin-notifications.digest_option')}
+
+
+ {digestFrequencyValues.map(v => (
+
+ ))}
+
+
+ )}
+
+ {t('talk-plugin-notifications.turn_off_all')}
+
);
@@ -76,9 +114,13 @@ Settings.propTypes = {
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
turnOffAll: PropTypes.func.isRequired,
- turnOffButtonDisabled: PropTypes.bool.isRequired,
+ disableTurnoffButton: PropTypes.bool.isRequired,
+ disableDigest: PropTypes.bool.isRequired,
needEmailVerification: PropTypes.bool.isRequired,
email: PropTypes.string,
+ digestFrequencyValues: PropTypes.array.isRequired,
+ digestFrequency: PropTypes.string.isRequired,
+ onChangeDigestFrequency: PropTypes.func.isRequired,
};
export default Settings;
diff --git a/plugins/talk-plugin-notifications/client/containers/Settings.js b/plugins/talk-plugin-notifications/client/containers/Settings.js
index 76a03462d..f3e6c7e6d 100644
--- a/plugins/talk-plugin-notifications/client/containers/Settings.js
+++ b/plugins/talk-plugin-notifications/client/containers/Settings.js
@@ -2,7 +2,11 @@ import React from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import Settings from '../components/Settings';
-import { withFragments, excludeIf } from 'plugin-api/beta/client/hocs';
+import {
+ withFragments,
+ excludeIf,
+ withEnumValues,
+} from 'plugin-api/beta/client/hocs';
import { getSlotFragmentSpreads } from 'plugin-api/beta/client/utils';
import { withUpdateNotificationSettings } from '../mutations';
@@ -33,6 +37,10 @@ class SettingsContainer extends React.Component {
this.props.updateNotificationSettings(this.state.turnOffInput);
};
+ setDigestFrequency = digestFrequency => {
+ this.props.updateNotificationSettings({ digestFrequency });
+ };
+
getNeedEmailVerification() {
return !this.props.root.me.profiles.some(
profile => profile.provider === 'local' && profile.confirmedAt
@@ -49,9 +57,15 @@ class SettingsContainer extends React.Component {
setTurnOffInputFragment={this.setTurnOffInputFragment}
updateNotificationSettings={this.props.updateNotificationSettings}
turnOffAll={this.turnOffAll}
- turnOffButtonDisabled={this.state.hasNotifications.length === 0}
needEmailVerification={this.getNeedEmailVerification()}
email={this.props.root.me.email}
+ digestFrequencyValues={this.props.digestFrequencyValues}
+ digestFrequency={
+ this.props.root.me.notificationSettings.digestFrequency
+ }
+ disableDigest={this.state.hasNotifications.length === 0}
+ disableTurnoffButton={this.state.hasNotifications.length === 0}
+ onChangeDigestFrequency={this.setDigestFrequency}
/>
);
}
@@ -61,6 +75,7 @@ SettingsContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
updateNotificationSettings: PropTypes.func.isRequired,
+ digestFrequencyValues: PropTypes.array.isRequired,
};
const enhance = compose(
@@ -70,6 +85,9 @@ const enhance = compose(
__typename
${getSlotFragmentSpreads(slots, 'root')}
me {
+ notificationSettings {
+ digestFrequency
+ }
email
profiles {
provider
@@ -85,7 +103,8 @@ const enhance = compose(
props =>
!props.root.me.profiles.some(profile => profile.provider === 'local')
),
- withUpdateNotificationSettings
+ withUpdateNotificationSettings,
+ withEnumValues('DIGEST_FREQUENCY', 'digestFrequencyValues')
);
export default enhance(SettingsContainer);
diff --git a/plugins/talk-plugin-notifications/client/graphql.js b/plugins/talk-plugin-notifications/client/graphql.js
index b48b63668..e99bc6a83 100644
--- a/plugins/talk-plugin-notifications/client/graphql.js
+++ b/plugins/talk-plugin-notifications/client/graphql.js
@@ -11,13 +11,38 @@ export default {
`,
},
mutations: {
- UpdateNotificationSettings: () => ({
+ UpdateNotificationSettings: ({
+ variables: { input },
+ state: { auth: { user: { id } } },
+ }) => ({
optimisticResponse: {
updateNotificationSettings: {
__typename: 'UpdateNotificationSettingsResponse',
errors: null,
},
},
+ update: proxy => {
+ if (input.digestFrequency === undefined) {
+ return;
+ }
+
+ const fragment = gql`
+ fragment TalkNotificationsCategoryReply_User_Fragment on User {
+ notificationSettings {
+ digestFrequency
+ }
+ }
+ `;
+ const fragmentId = `User_${id}`;
+ const data = {
+ __typename: 'User',
+ notificationSettings: {
+ __typename: 'NotificationSettings',
+ digestFrequency: input.digestFrequency,
+ },
+ };
+ proxy.writeFragment({ fragment, id: fragmentId, data });
+ },
}),
},
};
diff --git a/plugins/talk-plugin-notifications/client/translations.yml b/plugins/talk-plugin-notifications/client/translations.yml
index 0d74114d2..3ed123502 100644
--- a/plugins/talk-plugin-notifications/client/translations.yml
+++ b/plugins/talk-plugin-notifications/client/translations.yml
@@ -13,3 +13,6 @@ en:
banner_error:
title: Error
text: There has been an error sending your verification email. Please try again later.
+ digest_option: Send notifications
+ digest_enum:
+ NONE: Immediately
diff --git a/plugins/talk-plugin-notifications/package.json b/plugins/talk-plugin-notifications/package.json
index fbf9657fb..1ec166cc8 100644
--- a/plugins/talk-plugin-notifications/package.json
+++ b/plugins/talk-plugin-notifications/package.json
@@ -6,6 +6,7 @@
"license": "Apache-2.0",
"private": false,
"dependencies": {
+ "cron": "^1.3.0",
"linkifyjs": "^2.1.5"
}
}
diff --git a/plugins/talk-plugin-notifications/server/NotificationManager.js b/plugins/talk-plugin-notifications/server/NotificationManager.js
index 6b53d33a7..2d901de55 100644
--- a/plugins/talk-plugin-notifications/server/NotificationManager.js
+++ b/plugins/talk-plugin-notifications/server/NotificationManager.js
@@ -1,10 +1,24 @@
-const { get, find, groupBy, forEach, property } = require('lodash');
-const debug = require('debug')('talk-plugin-notifications');
-const uuid = require('uuid/v4');
const {
- UNSUBSCRIBE_SUBJECT,
- DISABLE_REQUIRE_EMAIL_VERIFICATIONS,
-} = require('./config');
+ merge,
+ map,
+ get,
+ find,
+ groupBy,
+ forEach,
+ flatten,
+ property,
+} = require('lodash');
+const debug = require('debug')('talk-plugin-notifications');
+const { DISABLE_REQUIRE_EMAIL_VERIFICATIONS } = require('./config');
+const { CronJob } = require('cron');
+const { getOrganizationName } = require('./util');
+const {
+ processNewNotifications,
+ filterSuperseded,
+ filterVerified,
+ sendNotification,
+} = require('./messages');
+const { renderDigestMessage } = require('./digests');
// handleHandlers will call the handle method on each handler to determine if a
// notification should be sent for it.
@@ -32,97 +46,11 @@ const handleHandlers = (ctx, handlers, ...args) =>
})
);
-// 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) {
this.context = context;
this.registry = [];
+ this.digests = [];
}
/**
@@ -149,17 +77,170 @@ class NotificationManager {
.map(({ category }) => category)
.join(', ')}] handlers when the '${event}' event is emitted`
);
- broker.on(event, this.handle(handlers));
+ broker.on(event, this.handleUserEvent(handlers));
});
}
/**
- * handle will wrap a notification handler and attach it to the notification
- * stream system.
+ * registerDigests will register the digest handlers.
+ *
+ * @param {Array |