Merge branch 'master' of github.com:coralproject/talk into rte-plugin

* 'master' of github.com:coralproject/talk: (103 commits)
  Implement new permlink view for real
  fixes to ci for deploy
  fixed deploy
  changed max retries on unreliable tests
  always save test results
  fixed workspace error
  implemented staff replies
  adjusted client tests
  seperated tests
  more fixes to translation
  fixed translation
  review
  object propery typo
  added safari to integration jobs
  added develop filter
  added deeper cache support
  added dist cache support
  fixed firefox support
  removed headless
  ammended browserstack test
  ...
This commit is contained in:
okbel
2018-03-02 12:30:22 -03:00
160 changed files with 4073 additions and 1759 deletions
+292
View File
@@ -0,0 +1,292 @@
# job_defaults applies all the defaults for each job.
job_defaults: &job_defaults
working_directory: ~/coralproject/talk
docker:
- image: circleci/node:8
# integration_environment is the environment that configures the tests.
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
<<: *job_defaults
environment:
<<: *integration_environment
docker:
- image: circleci/node:8-browsers
- image: circleci/mongo:3
- image: circleci/redis:4-alpine
steps:
- checkout
- attach_workspace:
at: ~/coralproject/talk
- run:
name: Setup the database with defaults
command: ./bin/cli setup --defaults
- run:
name: Run the integration tests
command: bash .circleci/e2e.sh
- store_test_results:
when: always
path: /tmp/circleci-test-results
version: 2
jobs:
# npm_dependencies will install the dependencies used by all other steps.
npm_dependencies:
<<: *job_defaults
steps:
- checkout
- attach_workspace:
at: ~/coralproject/talk
- restore_cache:
key: dependency-cache-{{ checksum "yarn.lock" }}
- run:
name: Install dependencies
command: |
yarn global add node-gyp &&
yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-{{ checksum "yarn.lock" }}
paths:
- ./node_modules
- persist_to_workspace:
root: .
paths: node_modules
# lint will perform file linting.
lint:
<<: *job_defaults
steps:
- checkout
- attach_workspace:
at: ~/coralproject/talk
- run:
name: Perform linting
command: yarn lint
# build_assets will build the static assets.
build_assets:
<<: *job_defaults
steps:
- checkout
- attach_workspace:
at: ~/coralproject/talk
- restore_cache:
keys:
- build-cache-{{ .Branch }}-{{ .Revision }}
- build-cache-{{ .Branch }}-
- build-cache-
- run:
name: Build static assets
command: yarn build
- save_cache:
key: build-cache-{{ .Branch }}-{{ .Revision }}
paths:
- ./node_modules/.cache/hard-source
- persist_to_workspace:
root: .
paths: dist
# test_unit will run the unit tests.
test_unit:
<<: *job_defaults
docker:
- image: circleci/node:8
- image: circleci/mongo:3
- image: circleci/redis:4-alpine
steps:
- checkout
- attach_workspace:
at: ~/coralproject/talk
- run:
name: Setup the test results directory
command: mkdir -p /tmp/circleci-test-results
- run:
name: Run the client unit tests
command: yarn test:client --ci
environment:
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
- store_test_results:
when: always
path: /tmp/circleci-test-results
# test_integration_chrome_local will run the integration tests locally with
# chrome headless.
test_integration_chrome_local:
<<: *integration_job
environment:
<<: *integration_environment
E2E_BROWSERS: chrome
# test_integration_firefox_local will run the integration tests locally with
# firefox headless.
test_integration_firefox_local:
<<: *integration_job
environment:
<<: *integration_environment
E2E_BROWSERS: firefox
# test_integration_chrome will run the integration tests with chrome in
# browserstack.
test_integration_chrome:
<<: *integration_job
environment:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: chrome
# test_integration_firefox will run the integration tests with firefox in
# browserstack.
test_integration_firefox:
<<: *integration_job
environment:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: firefox
# test_integration_edge will run the integration tests with edge in
# browserstack.
test_integration_edge:
<<: *integration_job
environment:
<<: *integration_environment
BROWSERSTACK: true
E2E_BROWSERS: edge
# test_integration_ie will run the integration tests with ie in
# browserstack.
test_integration_ie:
<<: *integration_job
environment:
<<: *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.
test_integration_safari:
<<: *integration_job
environment:
<<: *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:
<<: *job_defaults
steps:
- checkout
- setup_remote_docker
- run:
name: Deploy the code
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.
filter_deploy: &filter_deploy
filters:
branches:
only:
- master
- next
tags:
only: /v[0-9]+(\.[0-9]+)*/
# filter_develop will add the filters for a development related commit.
filter_develop: &filter_develop
filters:
branches:
ignore:
- master
- next
workflows:
version: 2
# All PR's will hit this workflow.
build-and-test:
jobs:
- npm_dependencies:
<<: *filter_develop
- lint:
<<: *filter_develop
requires:
- npm_dependencies
- test_unit:
<<: *filter_develop
requires:
- npm_dependencies
- build_assets:
<<: *filter_develop
requires:
- npm_dependencies
- test_integration_chrome_local:
<<: *filter_develop
requires:
- build_assets
- test_integration_firefox_local:
<<: *filter_develop
requires:
- build_assets
deploy-tagged:
jobs:
- npm_dependencies:
<<: *filter_deploy
- lint:
<<: *filter_deploy
requires:
- npm_dependencies
- test_unit:
<<: *filter_deploy
requires:
- npm_dependencies
- build_assets:
<<: *filter_deploy
requires:
- npm_dependencies
- test_integration_chrome:
<<: *filter_deploy
requires:
- build_assets
- test_integration_firefox:
<<: *filter_deploy
requires:
- build_assets
- test_integration_edge:
<<: *filter_deploy
requires:
- build_assets
- test_integration_ie:
<<: *filter_deploy
requires:
- build_assets
- test_integration_safari:
<<: *filter_deploy
requires:
- build_assets
- deploy:
<<: *filter_deploy
requires:
- lint
- test_unit
- test_integration_chrome
- test_integration_firefox
- test_integration_edge
# TODO: uncomment when more reliable
# - test_integration_ie
# - test_integration_safari
+3 -3
View File
@@ -19,11 +19,11 @@ if [[ "${E2E_DISABLE}" == "true" ]]; then
exit
fi
if [[ "${CIRCLE_BRANCH}" == "master" && -n "$BROWSERSTACK_KEY" ]]; then
if [[ "$BROWSERSTACK" == "true" && -n "$BROWSERSTACK_KEY" ]]; then
echo Testing on browserstack
yarn e2e --reports-folder "$REPORTS_FOLDER" --bs-key "$BROWSERSTACK_KEY" --retries "$E2E_MAX_RETRIES" --timeout "$E2E_WAIT_FOR_TIMEOUT" --browsers "$E2E_BROWSERS"
node scripts/e2e.js --reports-folder "$REPORTS_FOLDER" --retries "$E2E_MAX_RETRIES" --timeout "$E2E_WAIT_FOR_TIMEOUT" --browsers "$E2E_BROWSERS" --browserstack
else
# When browserstack is not available test locally using chrome headless.
echo Testing locally
yarn e2e --reports-folder "$REPORTS_FOLDER" --retries "$E2E_MAX_RETRIES" --headless
node scripts/e2e.js --reports-folder "$REPORTS_FOLDER" --retries "$E2E_MAX_RETRIES" --timeout "$E2E_WAIT_FOR_TIMEOUT" --browsers "$E2E_BROWSERS" --headless
fi
+2 -1
View File
@@ -2,4 +2,5 @@
dist
docs
node_modules
public
public
+30 -20
View File
@@ -23,39 +23,49 @@ browserstack.err
plugins.json
plugins/*
!plugins/talk-plugin-akismet
!plugins/talk-plugin-facebook-auth
!plugins/talk-plugin-google-auth
!plugins/talk-plugin-auth
!plugins/talk-plugin-respect
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-like
!plugins/talk-plugin-mod
!plugins/talk-plugin-love
!plugins/talk-plugin-viewing-options
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-permalink
!plugins/talk-plugin-deep-reply-count
!plugins/talk-plugin-facebook-auth
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-flag-details
!plugins/talk-plugin-google-auth
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-like
!plugins/talk-plugin-love
!plugins/talk-plugin-member-since
!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
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-respect
!plugins/talk-plugin-slack-notifications
!plugins/talk-plugin-sort-most-liked
!plugins/talk-plugin-sort-most-loved
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-sort-most-respected
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-member-since
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-deep-reply-count
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-subscriber
<<<<<<< HEAD
!plugins/talk-plugin-flag-details
!plugins/talk-plugin-slack-notifications
!plugins/talk-plugin-rich-text
!plugins/talk-plugin-rich-text-pell
!plugins/talk-plugin-profile-settings
=======
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-viewing-options
>>>>>>> 8d147dbf47104cdcfb8c3bc2ebb02b6c73c7b8ab
**/node_modules/*
yarn-error.log
+1 -1
View File
@@ -1,4 +1,4 @@
# Talk &middot; [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) &middot; [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8/badge)](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) &middot; [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests)
# Talk &middot; [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) &middot; [![NSP Status](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946/badge)](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946) &middot; [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests)
Online comments are broken. Our open-source commenting platform, Talk, rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html).
-8
View File
@@ -1,7 +1,6 @@
const express = require('express');
const morgan = require('morgan');
const path = require('path');
const uuid = require('uuid');
const merge = require('lodash/merge');
const helmet = require('helmet');
const plugins = require('./services/plugins');
@@ -13,13 +12,6 @@ const { ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT } = require('./config');
const app = express();
// Request Identity Middleware
app.use((req, res, next) => {
req.id = uuid.v4();
next();
});
//==============================================================================
// PLUGIN PRE APPLICATION MIDDLEWARE
//==============================================================================
+3 -7
View File
@@ -6,8 +6,7 @@
const util = require('./util');
const program = require('commander');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const jobs = require('../jobs');
const mongoose = require('../services/mongoose');
const kue = require('../services/kue');
@@ -21,11 +20,8 @@ function processJobs() {
// started.
util.onshutdown([() => kue.Task.shutdown()]);
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
// Start the jobs processor.
jobs.process();
}
/**
+2 -1
View File
@@ -143,7 +143,7 @@ async function reconcileRemotePlugins({ dryRun, upgradeRemote }) {
if (fetchable.length > 0) {
console.log(
`$ yarn add --ignore-scripts ${fetchable.map(
`$ yarn add --ignore-scripts --ignore-workspace-root-check ${fetchable.map(
({ name, version }) => `${name}@${version}`.cyan
)}`
);
@@ -152,6 +152,7 @@ async function reconcileRemotePlugins({ dryRun, upgradeRemote }) {
let args = [
'add',
'--ignore-scripts',
'--ignore-workspace-root-check',
...fetchable.map(({ name, version }) => `${name}@${version}`),
];
-75
View File
@@ -1,75 +0,0 @@
machine:
node:
version: 8
services:
- docker
- redis
environment:
PATH: "${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin"
NODE_ENV: "test"
MOCHA_FILE: "${CIRCLE_TEST_REPORTS}/junit/test-results.xml"
MOCHA_REPORTER: "mocha-junit-reporter"
pre:
# TODO: use the following to add in support for MongoDB 3.4.
# # Upgrade the database version to 3.4.
# - sudo apt-get purge mongodb-org*
# - sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 0C49F3730359A14518585931BC711F9BA15703C6
# - echo "deb [ arch=amd64 ] http://repo.mongodb.org/apt/ubuntu precise/mongodb-org/3.4 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.4.list
# - sudo apt-get update
# - sudo apt-get install -y mongodb-org
# - sudo service mongod restart
# Force sync the time.
- date
- sudo service ntp stop
- sudo ntpdate -s time.nist.gov
- sudo service ntp start
- date
# Install chromium for e2e and remove old google-chrome
- sudo rm -rf /opt/google/chrome
- sudo rm -f /usr/bin/google-chrome*
- sudo apt-get update
- sudo apt-get install chromium-browser
dependencies:
override:
# Install node dependencies.
- yarn --version
- yarn global add node-gyp --force
- yarn
post:
# Build the static assets.
- yarn build
# Lint the project here, before tests are ran.
- yarn lint
database:
post:
# Initialize the settings in the database, this will create indicies for the
# database.
- ./bin/cli setup --defaults
- sleep 2
test:
override:
# Run the tests using the junit reporter.
- yarn test
# Run the end to end tests
- yarn e2e:ci
deployment:
release:
tag: /v[0-9]+(\.[0-9]+)*/
commands:
- bash ./scripts/docker.sh deploy
latest:
branch:
- master
- next
owner: coralproject
commands:
- bash ./scripts/docker.sh deploy
+30 -10
View File
@@ -1,14 +1,34 @@
import { HANDLE_SUCCESSFUL_LOGIN } from 'coral-framework/constants/auth';
import { getStaticConfiguration } from 'coral-framework/services/staticConfiguration';
import { createPostMessage } from 'coral-framework/services/postMessage';
document.addEventListener('DOMContentLoaded', () => {
// Get the auth element and parse it as JSON by decoding it.
const auth = document.getElementById('auth');
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = auth.innerText;
try {
const staticConfig = getStaticConfiguration();
const { STATIC_ORIGIN: origin } = staticConfig;
const postMessage = createPostMessage(origin);
// Set the item in localStorage.
localStorage.setItem('auth', doc.body.textContent);
// Get the auth element and parse it as JSON by decoding it.
const auth = document.getElementById('auth');
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = auth.innerText;
// Close the window.
setTimeout(() => {
window.close();
}, 50);
// Auth state is contained within the node.
const { err, data } = JSON.parse(doc.body.textContent);
if (err) {
// TODO: send back the error message.
console.error(err);
} else {
// The data will contain a user and a token.
const { user, token } = data;
// Send the state back.
postMessage.post(HANDLE_SUCCESSFUL_LOGIN, { user, token });
}
} finally {
// Always close the window.
setTimeout(() => {
window.close();
}, 50);
}
});
@@ -1,75 +0,0 @@
import * as actions from '../constants/asset';
import { notify } from 'coral-framework/actions/notification';
import t from 'coral-framework/services/i18n';
export const fetchAssetRequest = () => ({ type: actions.FETCH_ASSET_REQUEST });
export const fetchAssetSuccess = asset => ({
type: actions.FETCH_ASSET_SUCCESS,
asset,
});
export const fetchAssetFailure = error => ({
type: actions.FETCH_ASSET_FAILURE,
error,
});
const updateAssetSettingsRequest = () => ({
type: actions.UPDATE_ASSET_SETTINGS_REQUEST,
});
const updateAssetSettingsSuccess = settings => ({
type: actions.UPDATE_ASSET_SETTINGS_SUCCESS,
settings,
});
const updateAssetSettingsFailure = error => ({
type: actions.UPDATE_ASSET_SETTINGS_FAILURE,
error,
});
export const updateConfiguration = newConfig => (
dispatch,
getState,
{ rest }
) => {
const assetId = getState().asset.id;
dispatch(updateAssetSettingsRequest());
rest(`/assets/${assetId}/settings`, { method: 'PUT', body: newConfig })
.then(() => {
dispatch(notify('success', t('framework.success_update_settings')));
dispatch(updateAssetSettingsSuccess(newConfig));
})
.catch(error => {
console.error(error);
dispatch(updateAssetSettingsFailure(error));
});
};
export const updateOpenStream = closedBody => (
dispatch,
getState,
{ rest }
) => {
const assetId = getState().asset.id;
dispatch(fetchAssetRequest());
rest(`/assets/${assetId}/status`, { method: 'PUT', body: closedBody })
.then(() => {
dispatch(notify('success', t('framework.success_update_settings')));
dispatch(fetchAssetSuccess(closedBody));
})
.catch(error => {
console.error(error);
dispatch(fetchAssetFailure(error));
});
};
const openStream = () => ({ type: actions.OPEN_COMMENTS });
const closeStream = () => ({ type: actions.CLOSE_COMMENTS });
export const updateOpenStatus = status => dispatch => {
if (status === 'open') {
dispatch(openStream());
dispatch(updateOpenStream({ closedAt: null }));
} else {
dispatch(closeStream());
dispatch(updateOpenStream({ closedAt: new Date().getTime() }));
}
};
@@ -1,6 +0,0 @@
import { ADD_EXTERNAL_CONFIG } from '../constants/config';
export const addExternalConfig = config => ({
type: ADD_EXTERNAL_CONFIG,
config,
});
@@ -1,14 +1,10 @@
import * as actions from '../constants/login';
import { checkLogin } from 'coral-framework/actions/auth';
export const showSignInDialog = () => ({
type: actions.SHOW_SIGNIN_DIALOG,
});
export const hideSignInDialog = () => dispatch => {
dispatch(checkLogin());
dispatch({ type: actions.HIDE_SIGNIN_DIALOG });
};
export const hideSignInDialog = () => ({ type: actions.HIDE_SIGNIN_DIALOG });
export const focusSignInDialog = () => ({
type: actions.FOCUS_SIGNIN_DIALOG,
@@ -1,10 +0,0 @@
export const FETCH_ASSET_REQUEST = 'FETCH_ASSET_REQUEST';
export const FETCH_ASSET_FAILURE = 'FETCH_ASSET_FAILURE';
export const FETCH_ASSET_SUCCESS = 'FETCH_ASSET_SUCCESS';
export const UPDATE_ASSET_SETTINGS_REQUEST = 'UPDATE_ASSET_SETTINGS_REQUEST';
export const UPDATE_ASSET_SETTINGS_SUCCESS = 'UPDATE_ASSET_SETTINGS_SUCCESS';
export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE';
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
@@ -1 +0,0 @@
export const ADD_EXTERNAL_CONFIG = 'ADD_EXTERNAL_CONFIG';
@@ -2,7 +2,6 @@ import React from 'react';
import { compose, gql } from 'react-apollo';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import isEqual from 'lodash/isEqual';
import get from 'lodash/get';
import branch from 'recompose/branch';
import renderComponent from 'recompose/renderComponent';
@@ -14,12 +13,11 @@ import {
hideSignInDialog,
} from '../actions/login';
import { updateStatus } from 'coral-framework/actions/auth';
import { fetchAssetSuccess } from '../actions/asset';
import {
getDefinitionName,
getSlotFragmentSpreads,
} from 'coral-framework/utils';
import { withQuery } from 'coral-framework/hocs';
import { withQuery, withPopupAuthHandler } from 'coral-framework/hocs';
import Embed from '../components/Embed';
import Stream from '../tabs/stream/containers/Stream';
import AutomaticAssetClosure from './AutomaticAssetClosure';
@@ -107,12 +105,6 @@ class EmbedContainer extends React.Component {
this.props.data.refetch();
this.resubscribe(nextProps);
}
const { fetchAssetSuccess } = this.props;
if (!isEqual(nextProps.root.asset, this.props.root.asset)) {
// TODO: remove asset data from redux store.
fetchAssetSuccess(nextProps.root.asset);
}
}
componentDidUpdate(prevProps) {
@@ -298,7 +290,6 @@ EmbedContainer.propTypes = {
activeTab: PropTypes.string,
parentUrl: PropTypes.string,
data: PropTypes.object,
fetchAssetSuccess: PropTypes.func,
showSignInDialog: PropTypes.bool,
signInDialogFocus: PropTypes.bool,
};
@@ -322,7 +313,6 @@ const mapDispatchToProps = dispatch =>
bindActionCreators(
{
setActiveTab,
fetchAssetSuccess,
notify,
focusSignInDialog,
blurSignInDialog,
@@ -333,6 +323,7 @@ const mapDispatchToProps = dispatch =>
);
export default compose(
withPopupAuthHandler,
connect(mapStateToProps, mapDispatchToProps),
branch(props => !props.checkedInitialLogin, renderComponent(Spinner)),
withEmbedQuery
+33 -8
View File
@@ -141,7 +141,7 @@ export function findCommentInAsset(asset, callbackOrId) {
callback = node => node.id === callbackOrId;
}
if (asset.comment) {
return findComment([getTopLevelParent(asset.comment)], callback);
return findComment([reverseCommentParentTree(asset.comment)], callback);
}
if (!asset.comments) {
return false;
@@ -187,11 +187,12 @@ export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) {
);
}
/**
* attachCommentToParent recurses through the comment tree starting at `topLevelComment`
* to find the parent of `comment` and attach it to the replies.
*/
export function attachCommentToParent(topLevelComment, comment) {
function attachComment(topLevelComment, comment) {
if (!topLevelComment.replies) {
topLevelComment = update(topLevelComment, {
replies: { $set: { nodes: [] } },
});
}
if (topLevelComment.id === comment.parent.id) {
return update(topLevelComment, {
replies: {
@@ -204,16 +205,40 @@ export function attachCommentToParent(topLevelComment, comment) {
},
});
}
if (!topLevelComment.replies.nodes.length) {
return topLevelComment;
}
return update(topLevelComment, {
replies: {
nodes: {
$apply: nodes =>
nodes.map(node => attachCommentToParent(node, comment)),
$apply: nodes => nodes.map(node => attachComment(node, comment)),
},
},
});
}
/**
* attachCommentToParent recurses through the comment tree starting at `topLevelComment`
* to find the ancestor of `comment` and attach it to the replies.
*/
export function attachCommentToParent(topLevelComment, comment) {
let result = topLevelComment;
if (comment.parent.parent) {
result = attachCommentToParent(result, comment.parent);
}
return attachComment(result, comment);
}
/**
* reverseCommentParentTree reverses a comment parent relationship tree
* like `comment -> parent -> parent` into `parent -> parent -> comment -> replies`.
*/
export function reverseCommentParentTree(comment) {
return comment.parent
? attachCommentToParent(getTopLevelParent(comment), comment)
: comment;
}
/**
* Nest a string in itself repeatly until `level` has been reached.
*
@@ -1,28 +0,0 @@
import * as actions from '../constants/asset';
const initialState = {
closedAt: null,
settings: null,
title: null,
url: null,
features: {},
status: 'open',
moderation: null,
};
export default function asset(state = initialState, action) {
switch (action.type) {
case actions.FETCH_ASSET_SUCCESS:
return {
...state,
...action.asset,
};
case actions.UPDATE_ASSET_SETTINGS_SUCCESS:
return {
...state,
settings: action.settings,
};
default:
return state;
}
}
@@ -1,15 +0,0 @@
import { ADD_EXTERNAL_CONFIG } from '../constants/config';
const initialState = {};
export default function config(state = initialState, action) {
switch (action.type) {
case ADD_EXTERNAL_CONFIG:
return {
...state,
...action.config,
};
default:
return state;
}
}
@@ -1,5 +1,4 @@
import login from './login';
import asset from './asset';
import embed from './embed';
import configure from './configure';
import stream from './stream';
@@ -7,7 +6,6 @@ import profile from './profile';
export default {
login,
asset,
embed,
configure,
stream,
@@ -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 {
@@ -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';
@@ -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,51 @@ 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,
depth,
comment,
parentId,
postComment,
currentUser,
setActiveReplyBox,
maxCharCount,
notify,
charCountEnable,
} = this.props;
return (
<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}
/>
);
}
renderReplies(view) {
const {
asset,
data,
root,
depth,
comment,
postFlag,
parentId,
highlighted,
postComment,
currentUser,
postDontAgree,
setActiveReplyBox,
activeReplyBox,
loadMore,
@@ -372,39 +407,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 +455,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 +518,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,183 +544,183 @@ 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}
/>
{comment.editing && comment.editing.edited ? (
<span>
&nbsp;<span className={styles.editedMarker}>
({t('comment.edited')})
</span>
</span>
) : null}
</span>
</div>
<div
className={cn(
styles.tagsContainer,
'talk-stream-comment-header-tags-container'
<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>
)}
>
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
</span>
)}
{!isActive && <InactiveCommentLabel status={comment.status} />}
</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
className={cn(
styles.commentAuthorTagsSlot,
'talk-stream-comment-author-tags'
)}
fill="commentAuthorTags"
queryData={queryData}
fill="commentReactions"
{...slotProps}
queryData={queryData}
inline
/>
</div>
<span
className={`${
styles.bylineSecondary
} talk-stream-comment-user-byline`}
>
<Slot
fill="commentTimestamp"
defaultComponent={CommentTimestamp}
className={'talk-stream-comment-published-date'}
created_at={comment.created_at}
queryData={queryData}
{...slotProps}
/>
{comment.editing && comment.editing.edited ? (
<span>
&nbsp;<span className={styles.editedMarker}>
({t('comment.edited')})
</span>
</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} />}
</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
/>
{!disableReply && (
<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}
<ReplyButton
onClick={this.showReplyBox}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
/>
</ActionButton>
</div>
)}
</div>
)}
</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>
);
}
<<<<<<< HEAD
{activeReplyBox === comment.id ? (
<ReplyBox
comment={comment}
@@ -715,6 +777,43 @@ export default class Comment extends React.Component {
loadingState={loadingState}
/>
</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()}
>>>>>>> 8d147dbf47104cdcfb8c3bc2ebb02b6c73c7b8ab
</div>
);
}
@@ -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}
@@ -67,7 +67,7 @@ const withAnimateEnter = hoistStatics(BaseComponent => {
return WithAnimateEnter;
});
const singleCommentFragment = gql`
export const singleCommentFragment = gql`
fragment CoralEmbedStream_Comment_SingleComment on Comment {
id
body
@@ -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}
`,
};
+14 -2
View File
@@ -63,13 +63,25 @@ export const setAuthToken = token => (dispatch, _, { localStorage }) => {
export const handleSuccessfulLogin = (user, token) => (
dispatch,
_,
{ client, localStorage }
{ client, localStorage, postMessage }
) => {
const { exp } = jwtDecode(token);
if (localStorage) {
localStorage.setItem('exp', jwtDecode(token).exp);
localStorage.setItem('exp', exp);
localStorage.setItem('token', token);
}
// Send the message via the messages service to the window.opener if it
// exists.
if (window.opener) {
postMessage.post(
actions.HANDLE_SUCCESSFUL_LOGIN,
{ user, token },
window.opener
);
}
client.resetWebsocket();
dispatch({
+2 -1
View File
@@ -72,7 +72,8 @@ class Slot extends React.Component {
} = this.props;
const { plugins } = this.context;
let children = this.getChildren();
const pluginConfig = get(reduxState, 'config.pluginConfig') || emptyConfig;
const pluginConfig =
get(reduxState, 'config.plugins_config') || emptyConfig;
if (children.length === 0 && DefaultComponent) {
const props = plugins.getSlotComponentProps(
DefaultComponent,
@@ -17,6 +17,7 @@ class TalkProvider extends React.Component {
store: this.props.store,
pymLocalStorage: this.props.pymLocalStorage,
pymSessionStorage: this.props.pymSessionStorage,
postMessage: this.props.postMessage,
};
}
@@ -39,6 +40,7 @@ TalkProvider.childContextTypes = {
pymSessionStorage: PropTypes.object,
history: PropTypes.object,
store: PropTypes.object,
postMessage: PropTypes.object,
};
TalkProvider.propTypes = TalkProvider.childContextTypes;
+1 -1
View File
@@ -698,7 +698,7 @@ export const withCloseAsset = withMutation(
const fragmentId = `Asset_${id}`;
const data = {
__typename: 'Asset',
closedAt: new Date(),
closedAt: new Date().toISOString(),
isClosed: true,
};
proxy.writeFragment({ fragment, id: fragmentId, data });
+1
View File
@@ -10,6 +10,7 @@ export { default as withSignIn } from './withSignIn';
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 withResendEmailConfirmation,
} from './withResendEmailConfirmation';
@@ -0,0 +1,54 @@
import React from 'react';
import hoistStatics from 'recompose/hoistStatics';
import PropTypes from 'prop-types';
import { HANDLE_SUCCESSFUL_LOGIN } from 'coral-framework/constants/auth';
import {
handleSuccessfulLogin,
setAuthToken,
} from 'coral-framework/actions/auth';
/**
* WithPopupAuthHandler listens to successful logins over
* the `postMessage` service.
*/
export default hoistStatics(WrappedComponent => {
class WithPopupAuthHandler extends React.Component {
static contextTypes = {
store: PropTypes.object,
postMessage: PropTypes.object,
};
constructor(props, context) {
super(props, context);
context.postMessage.subscribe(this.handleAuth);
}
componentWillUnmount() {
this.context.postMessage.unsubscribe(this.handleAuth);
}
handleAuth = ({ name, data }) => {
if (name !== HANDLE_SUCCESSFUL_LOGIN) {
return;
}
const { store } = this.context;
// data will contain the user and token.
const { user, token } = data;
if (user && token) {
store.dispatch(handleSuccessfulLogin(user, token));
} else if (token) {
store.dispatch(setAuthToken(token));
} else {
console.error('Invalid auth data supplied', data);
}
};
render() {
return <WrappedComponent {...this.props} />;
}
}
return WithPopupAuthHandler;
});
+21
View File
@@ -0,0 +1,21 @@
export default class Action {
listeners = [];
listen = cb => {
this.listeners.push(cb);
};
unlisten = cb => {
this.listeners = this.listeners.filter(i => i !== cb);
};
event = { listen: this.listen, unlisten: this.unlisten };
call(...args) {
this.listeners.forEach(cb => cb(...args));
}
asEvent() {
return this.event;
}
}
@@ -1,9 +1,15 @@
import { MERGE_CONFIG } from '../constants/config';
import { LOGOUT } from '../constants/auth';
const initialState = {};
export default function config(state = initialState, action) {
switch (action.type) {
case LOGOUT:
return {
...state,
auth_token: null,
};
case MERGE_CONFIG:
return {
...state,
+5 -1
View File
@@ -14,6 +14,7 @@ import { createPluginsService } from './plugins';
import { createNotificationService } from './notification';
import { createGraphQLRegistry } from './graphqlRegistry';
import { createGraphQLService } from './graphql';
import { createPostMessage } from './postMessage';
import globalFragments from 'coral-framework/graphql/fragments';
import {
createStorage,
@@ -118,7 +119,7 @@ export async function createContext({
});
const staticConfig = getStaticConfiguration();
let { LIVE_URI: liveUri } = staticConfig;
let { LIVE_URI: liveUri, STATIC_ORIGIN: origin } = staticConfig;
if (liveUri == null) {
// The protocol must match the origin protocol, secure/insecure.
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
@@ -128,6 +129,8 @@ export async function createContext({
liveUri = `${protocol}://${location.host}${BASE_PATH}api/v1/live`;
}
const postMessage = createPostMessage(origin);
const client = createClient({
uri: `${BASE_PATH}api/v1/graph/ql`,
liveUri,
@@ -158,6 +161,7 @@ export async function createContext({
pymLocalStorage,
pymSessionStorage,
inIframe,
postMessage,
};
// Load framework fragments.
@@ -0,0 +1,87 @@
import set from 'lodash/set';
import has from 'lodash/has';
import get from 'lodash/get';
import remove from 'lodash/remove';
/**
* createPostMessage returns a service that deals with cross
* window communication using the postMessage API.
* @param {string} origin
* @param {string} scope
* @return {Object} postMessage service
*/
export function createPostMessage(origin, scope = 'client') {
// Store a reference to each listener added.
const listeners = {};
// withOriginCheck wraps a given handler for a messages event with a check to
// see if the origin matches.
const withOriginCheck = handler => {
return event => {
if (get(event, 'origin') !== origin) {
return;
}
if (get(event, 'data.scope') !== scope) {
return;
}
// Pass the handler the event details.
handler(event);
};
};
// withParseData will parse the data.
const withParseData = handler => {
return event => {
const data = get(event, 'data.data');
const name = get(event, 'data.name');
if (!data || !name) {
return;
}
handler({ data, name, event });
};
};
return {
post(name, data, target = window.opener) {
if (!target) {
return;
}
// Serialize the message to be sent via postMessage.
const msg = { name, data, scope };
// Send the message.
target.postMessage(msg, origin);
},
subscribe: (handler, target = window) => {
// If this handler is already attached to the target, detach it.
if (has(listeners, [target, handler])) {
this.unsubscribeFromMessages(handler, target);
}
// Wrap the listener with a origin check.
const listener = withOriginCheck(withParseData(handler));
// Save a reference to the compiled listener.
set(listeners, [target, handler], listener);
// Attach the listener to the target.
target.addEventListener('message', listener);
},
unsubscribe: (handler, target = window) => {
if (!has(listeners, [target, handler])) {
return;
}
const listener = get(listeners, [target, handler]);
// Remove the listener from the target.
target.removeEventListener('message', listener);
remove(listeners, [target, handler]);
},
};
}
+14 -7
View File
@@ -1,9 +1,10 @@
import uuid from 'uuid/v4';
function getStorage(type) {
let storage = window[type],
x = '__storage_test__';
let storage;
try {
storage = window[type];
const x = '__storage_test__';
storage.setItem(x, x);
storage.removeItem(x);
} catch (e) {
@@ -11,6 +12,8 @@ function getStorage(type) {
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// SecurityError related to having 3rd party cookies disabled.
e.code === 18 ||
// Firefox
e.code === 1014 ||
@@ -19,14 +22,18 @@ function getStorage(type) {
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
// acknowledge QuotaExceededError only if there's something already stored
storage.length !== 0;
e.name === 'NS_ERROR_DOM_QUOTA_REACHED');
if (!ignore) {
console.warning(e); // eslint-disable-line
return null;
console.warn(e);
}
// When third party cookies are disabled, session storage is readable/
// writable, but localStorage is not. Try to get the sessionStorage to use.
if (type !== 'sessionStorage') {
return getStorage('sessionStorage');
}
}
return storage;
}
+39
View File
@@ -118,3 +118,42 @@ Configuration:
- `TALK_AKISMET_API_KEY` (**required**) - The Akismet API key located on your account page.
- `TALK_AKISMET_SITE` (**required**) - The URL where you are embedding the comment stream on to provide context to Akismet. If you're hosting talk on https://talk.mynews.org/, and your news site is https://mynews.org/, then you should set this parameter to `https://mynews.org/`
## talk-plugin-notifications
Source: [plugins/talk-plugin-notifications](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications){:target="_blank"}
Enables the Notification system for sending out enabled email notifications to
users when they interact with Talk. By itself, this plugin will not send
anything. You need to enable one of the `talk-plugin-notifications-category-*` plugins.
**Note that all `talk-plugin-notifications-*` plugins must be registered
*before* this plugin in order to work. For example:**
```js
{
"server": [
// ...
"talk-plugin-notifications-category-reply",
"talk-plugin-notifications",
// ...
]
}
```
{:.no-copy}
### talk-plugin-notifications-category-reply
{:.param}
Source: [plugins/talk-plugin-notifications-category-reply](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications-category-reply){:target="_blank"}
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.
+1 -1
View File
@@ -264,7 +264,7 @@ pre {
.toc {
a {
@extend .coral-link;
border-bottom: none;
border-bottom: none !important;
}
}
+29 -6
View File
@@ -1,9 +1,23 @@
const debug = require('debug')('talk:graph:connectors');
const merge = require('lodash/merge');
// Config.
const config = require('../config');
// Secrets.
const secrets = require('../secrets');
// Errors.
const errors = require('../errors');
// Graph.
const { getBroker } = require('./subscriptions/broker');
const { getPubsub } = require('./subscriptions/pubsub');
const resolvers = require('./resolvers');
const mutators = require('./mutators');
const loaders = require('./loaders');
const schema = require('./schema');
// Models.
const Action = require('../models/action');
const Asset = require('../models/asset');
@@ -28,7 +42,6 @@ const Moderation = require('../services/moderation');
const Mongoose = require('../services/mongoose');
const Passport = require('../services/passport');
const Plugins = require('../services/plugins');
const Pubsub = require('../services/pubsub');
const Redis = require('../services/redis');
const Regex = require('../services/regex');
const Scraper = require('../services/scraper');
@@ -40,9 +53,11 @@ const Tokens = require('../services/tokens');
const Users = require('../services/users');
const Wordlist = require('../services/wordlist');
// Connector.
const connectors = {
// Connectors.
const defaultConnectors = {
errors,
config,
secrets,
models: {
Action,
Asset,
@@ -67,7 +82,6 @@ const connectors = {
Mongoose,
Passport,
Plugins,
Pubsub,
Redis,
Regex,
Scraper,
@@ -79,14 +93,23 @@ const connectors = {
Users,
Wordlist,
},
graph: {
subscriptions: { getBroker, getPubsub },
resolvers,
mutators,
loaders,
schema,
},
};
module.exports = Plugins.get('server', 'connectors').reduce(
const connectors = Plugins.get('server', 'connectors').reduce(
(defaultConnectors, { plugin, connectors: pluginConnectors }) => {
debug(`adding plugin '${plugin.name}'`);
// Merge in the plugin connectors.
return merge(defaultConnectors, pluginConnectors);
},
connectors
defaultConnectors
);
module.exports = connectors;
+58 -11
View File
@@ -1,12 +1,13 @@
const loaders = require('./loaders');
const mutators = require('./mutators');
const uuid = require('uuid');
const merge = require('lodash/merge');
const uuid = require('uuid/v4');
const connectors = require('./connectors');
const { get, merge } = require('lodash');
const plugins = require('../services/plugins');
const pubsub = require('../services/pubsub');
const { getBroker } = require('./subscriptions/broker');
const debug = require('debug')('talk:graph:context');
const { createLogger } = require('../services/logging');
const { graphql } = require('graphql');
/**
* Contains the array of plugins that provide context to the server, these top
@@ -45,15 +46,16 @@ const decorateContextPlugins = (context, contextPlugins) => {
* Stores the request context.
*/
class Context {
constructor(parent) {
constructor(ctx) {
// Generate a new context id for the request if the parent doesn't provide
// one.
this.id = parent.id || uuid.v4();
this.id = ctx.id || uuid.v4();
// Attach a logger or create one.
this.log = ctx.log || createLogger('context', this.id);
// Load the current logged in user to `user`, otherwise this will be null.
if (parent.user) {
this.user = parent.user;
}
this.user = get(ctx, 'user');
// Attach the connectors.
this.connectors = connectors;
@@ -68,14 +70,48 @@ class Context {
this.plugins = decorateContextPlugins(this, contextPlugins);
// Bind the publish/subscribe to the context.
this.pubsub = pubsub.getClient();
this.pubsub = getBroker();
// Bind the parent context.
this.parent = parent;
this.parent = ctx;
}
/**
* graphql will execute a graph request for the current context.
*
* @param {String} requestString A GraphQL language formatted string
* representing the requested operation.
* @param {Object} variableValues A mapping of variable name to runtime value
* to use for all variables defined in the requestString.
* @param {Object} rootValue The value provided as the first argument to
* resolver functions on the top level type (e.g. the query object type).
* @param {String} operationName The name of the operation to use if
* requestString contains multiple possible operations. Can be omitted if
* requestString contains only one operation.
* @returns {Promise}
*/
async graphql(
requestString,
variableValues = {},
rootValue = {},
operationName = undefined
) {
// Perform the graph request directly using the graphql client.
return graphql(
// Use the connected graph schema.
this.connectors.graph.schema,
requestString,
rootValue,
// Use this, the context as the context.
this,
variableValues,
operationName
);
}
/**
* forSystem returns a system context object that can be used for internal
* operations.
*/
static forSystem() {
const { models: { User } } = connectors;
@@ -87,4 +123,15 @@ class Context {
}
}
// Attach the Context to the connectors.
connectors.graph.Context = Context;
// Connect the connect based plugins after the server has started.
plugins.defer('server', 'connect', ({ plugin, connect }) => {
debug(`connecting plugin to connectors '${plugin.name}'`);
// Pass the connectors down to the connect plugin.
connect(connectors);
});
module.exports = Context;
+3 -1
View File
@@ -2,6 +2,7 @@ const schema = require('./schema');
const Context = require('./context');
const { createSubscriptionManager } = require('./subscriptions');
const { ENABLE_TRACING } = require('../config');
const connectors = require('./connectors');
module.exports = {
createGraphOptions: req => ({
@@ -10,11 +11,12 @@ module.exports = {
// Load in the new context here, this will create the loaders + mutators for
// the lifespan of this request.
context: new Context(req),
context: new Context(req.context),
// Tracing request options, needed for Apollo Engine.
tracing: ENABLE_TRACING,
cacheControl: ENABLE_TRACING,
}),
createSubscriptionManager,
connectors,
};
+11 -16
View File
@@ -1,12 +1,7 @@
const DataLoader = require('dataloader');
const util = require('./util');
const { SEARCH_OTHER_USERS } = require('../../perms/constants');
const UsersService = require('../../services/users');
const { escapeRegExp } = require('../../services/regex');
const UserModel = require('../../models/user');
const mergeState = (query, state) => {
const { status } = state;
@@ -51,17 +46,14 @@ const mergeState = (query, state) => {
}
};
const genUserByIDs = async (context, ids) => {
const genUserByIDs = async (ctx, ids) => {
if (!ids || ids.length === 0) {
return [];
}
if (ids.length === 1) {
const user = await UsersService.findById(ids[0]);
return [user];
}
const { connectors: { models: { User } } } = ctx;
return UsersService.findByIdArray(ids).then(util.singleJoinBy(ids, 'id'));
return User.find({ id: { $in: ids } }).then(util.singleJoinBy(ids, 'id'));
};
/**
@@ -71,10 +63,10 @@ const genUserByIDs = async (context, ids) => {
* @param {Object} query query terms to apply to the users query
*/
const getUsersByQuery = async (
{ user },
{ user, connectors: { models: { User } } },
{ limit, cursor, value = '', state, action_type, sortOrder }
) => {
let query = UserModel.find();
let query = User.find();
if (action_type || state || value.length > 0) {
if (!user || !user.can(SEARCH_OTHER_USERS)) {
@@ -182,8 +174,11 @@ const getUsersByQuery = async (
* @return {Promise} resolves to the counts of the users from the
* query
*/
const getCountByQuery = async ({ user }, { action_type, state }) => {
let query = UserModel.find();
const getCountByQuery = async (
{ user, connectors: { models: { User } } },
{ action_type, state }
) => {
const query = User.find();
if (action_type || state) {
if (!user || !user.can(SEARCH_OTHER_USERS)) {
@@ -203,7 +198,7 @@ const getCountByQuery = async ({ user }, { action_type, state }) => {
}
}
return UserModel.find(query).count();
return query.count();
};
/**
+3 -9
View File
@@ -1,4 +1,4 @@
const { SEARCH_OTHER_USERS } = require('../../perms/constants');
const { decorateUserField } = require('./util');
const Action = {
__resolveType({ action_type }) {
@@ -11,14 +11,8 @@ const Action = {
return undefined;
}
},
// This will load the user for the specific action. We'll limit this to the
// admin users only or the current logged in user.
user({ user_id }, _, { loaders: { Users }, user }) {
if (user && (user.can(SEARCH_OTHER_USERS) || user_id === user.id)) {
return Users.getByID.load(user_id);
}
},
};
decorateUserField(Action, 'user', 'user_id');
module.exports = Action;
+10 -10
View File
@@ -1,4 +1,6 @@
const { decorateWithTags } = require('./util');
const { property } = require('lodash');
const { SEARCH_ACTIONS } = require('../../perms/constants');
const { decorateWithTags, decorateWithPermissionCheck } = require('./util');
const Comment = {
hasParent({ parent_id }) {
@@ -29,15 +31,8 @@ const Comment = {
return Comments.getByQuery(query);
},
replyCount({ reply_count }) {
// A simple remap from the underlying database model to the graph model.
return reply_count;
},
actions({ id }, _, { user, loaders: { Actions } }) {
if (!user || !user.can('SEARCH_ACTIONS')) {
return null;
}
replyCount: property('reply_count'),
actions({ id }, _, { loaders: { Actions } }) {
return Actions.getByID.load(id);
},
action_summaries(comment, _, { loaders: { Actions } }) {
@@ -65,4 +60,9 @@ const Comment = {
// Decorate the Comment type resolver with a tags field.
decorateWithTags(Comment);
// Protect direct action access.
decorateWithPermissionCheck(Comment, {
actions: [SEARCH_ACTIONS],
});
module.exports = Comment;
+7 -14
View File
@@ -1,18 +1,11 @@
const FlagAction = {
// Stored in the metadata, extract and return.
message({ metadata: { message } }) {
return message;
},
reason({ group_id }) {
return group_id;
},
user({ user_id }, _, { loaders: { Users } }) {
if (!user_id) {
return null;
}
const { decorateUserField } = require('./util');
const { property } = require('lodash');
return Users.getByID.load(user_id);
},
const FlagAction = {
message: property('metadata.message'),
reason: property('group_id'),
};
decorateUserField(FlagAction, 'user', 'user_id');
module.exports = FlagAction;
+3 -3
View File
@@ -1,7 +1,7 @@
const { property } = require('lodash');
const FlagActionSummary = {
reason({ group_id }) {
return group_id;
},
reason: property('group_id'),
};
module.exports = FlagActionSummary;
+2 -2
View File
@@ -1,4 +1,4 @@
const _ = require('lodash');
const { merge } = require('lodash');
const debug = require('debug')('talk:graph:resolvers');
const Action = require('./action');
@@ -70,7 +70,7 @@ resolvers = plugins
.reduce((acc, { plugin, resolvers }) => {
debug(`added plugin '${plugin.name}'`);
return _.merge(acc, resolvers);
return merge(acc, resolvers);
}, resolvers);
module.exports = resolvers;
+31 -25
View File
@@ -1,3 +1,4 @@
const { decorateWithPermissionCheck } = require('./util');
const {
SEARCH_ASSETS,
SEARCH_OTHERS_COMMENTS,
@@ -5,11 +6,7 @@ const {
} = require('../../perms/constants');
const RootQuery = {
assets(_, { query }, { loaders: { Assets }, user }) {
if (user == null || !user.can(SEARCH_ASSETS)) {
return null;
}
assets(_, { query }, { loaders: { Assets } }) {
return Assets.getByQuery(query);
},
asset(_, query, { loaders: { Assets } }) {
@@ -33,11 +30,7 @@ const RootQuery = {
return Comments.get.load(id);
},
async commentCount(_, { query }, { user, loaders: { Comments, Assets } }) {
if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) {
return null;
}
async commentCount(_, { query }, { loaders: { Comments, Assets } }) {
const { asset_url, asset_id } = query;
if (
(!asset_id || asset_id.length === 0) &&
@@ -53,11 +46,7 @@ const RootQuery = {
return Comments.getCountByQuery(query);
},
async userCount(_, { query }, { user, loaders: { Users } }) {
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
async userCount(_, { query }, { loaders: { Users } }) {
return Users.getCountByQuery(query);
},
@@ -72,23 +61,40 @@ const RootQuery = {
},
// this returns an arbitrary user
user(_, { id }, { user, loaders: { Users } }) {
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
user(_, { id }, { loaders: { Users } }) {
return Users.getByID.load(id);
},
// This endpoint is used for loading the user moderation queues (users whose username has been flagged),
// so hide it in the event that we aren't an admin.
users(_, { query }, { user, loaders: { Users } }) {
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
users(_, { query }, { loaders: { Users } }) {
return Users.getByQuery(query);
},
};
// Protect some query fields that are privileged.
decorateWithPermissionCheck(RootQuery, {
assets: [SEARCH_ASSETS],
users: [SEARCH_OTHER_USERS],
userCount: [SEARCH_OTHER_USERS],
commentCount: [SEARCH_OTHERS_COMMENTS],
});
// Protect the user field so only users who have permission to look up another
// user may do so as well as a user looking up themselves.
decorateWithPermissionCheck(
RootQuery,
{
user: [SEARCH_OTHER_USERS],
},
(obj, { id }, { user }) => {
if (user && user.id === id) {
return true;
}
// We don't return false because we want to fallthrough to the permission
// check if the custom check fails.
}
);
module.exports = RootQuery;
+21 -38
View File
@@ -1,40 +1,23 @@
const Subscription = {
commentAdded(comment) {
return comment;
},
commentEdited(comment) {
return comment;
},
commentAccepted(comment) {
return comment;
},
commentRejected(comment) {
return comment;
},
commentReset(comment) {
return comment;
},
commentFlagged(comment) {
return comment;
},
userBanned(user) {
return user;
},
userSuspended(user) {
return user;
},
usernameApproved(user) {
return user;
},
usernameRejected(user) {
return user;
},
usernameFlagged(user) {
return user;
},
usernameChanged(payload) {
return payload;
},
};
const Subscription = {};
// All of the subscription endpoints need to have an object to serialize when
// pushing out via PubSub, this simply ensures that all these entries will
// return the root object from the subscription to the PubSub framework.
[
'commentAdded',
'commentEdited',
'commentAccepted',
'commentRejected',
'commentReset',
'commentFlagged',
'userBanned',
'userSuspended',
'usernameApproved',
'usernameRejected',
'usernameFlagged',
'usernameChanged',
].forEach(field => {
Subscription[field] = obj => obj;
});
module.exports = Subscription;
+4 -8
View File
@@ -1,11 +1,7 @@
const { SEARCH_OTHER_USERS } = require('../../perms/constants');
const { decorateUserField } = require('./util');
const TagLink = {
assigned_by({ assigned_by }, _, { user, loaders: { Users } }) {
if (user && user.can(SEARCH_OTHER_USERS) && assigned_by != null) {
return Users.getByID.load(assigned_by);
}
},
};
const TagLink = {};
decorateUserField(TagLink, 'assigned_by');
module.exports = TagLink;
+39 -56
View File
@@ -1,4 +1,8 @@
const { decorateWithTags } = require('./util');
const {
decorateWithTags,
decorateWithPermissionCheck,
checkSelfField,
} = require('./util');
const KarmaService = require('../../services/karma');
const {
SEARCH_ACTIONS,
@@ -7,86 +11,65 @@ const {
VIEW_USER_ROLE,
LIST_OWN_TOKENS,
VIEW_USER_STATUS,
VIEW_USER_EMAIL,
} = require('../../perms/constants');
const { property } = require('lodash');
const User = {
action_summaries(user, _, { loaders: { Actions } }) {
return Actions.getSummariesByItem.load(user);
},
actions({ id }, _, { user, loaders: { Actions } }) {
// Only return the actions if the user is not an admin.
if (user && user.can(SEARCH_ACTIONS)) {
return Actions.getByID.load(id);
}
actions({ id }, _, { loaders: { Actions } }) {
return Actions.getByID.load(id);
},
comments({ id }, { query }, { loaders: { Comments }, user }) {
// If there is no user, or there is a user, but they are requesting someone
// else's comments, and they aren't allowed, don't return then anything!
if (!user || (user.id !== id && !user.can(SEARCH_OTHERS_COMMENTS))) {
return null;
}
comments({ id }, { query }, { loaders: { Comments } }) {
// Set the author id on the query.
query.author_id = id;
return Comments.getByQuery(query);
},
profiles({ profiles }, _, { user }) {
// if the user is not an admin, do not return the profiles
if (user && user.can(SEARCH_OTHER_USERS)) {
return profiles;
}
return null;
},
tokens({ id, tokens }, args, { user }) {
if (!user || (user.id !== id && !user.can(LIST_OWN_TOKENS))) {
return null;
}
return tokens;
},
ignoredUsers({ id }, args, { user, loaders: { Users } }) {
// Only allow a logged in user that is either the current user or is a staff
// member to access the ignoredUsers of a given user.
if (!user || (user.id !== id && !user.can(SEARCH_OTHER_USERS))) {
return null;
}
ignoredUsers({ ignoresUsers }, args, { user, loaders: { Users } }) {
// Return nothing if there is nothing to query for.
if (!user.ignoresUsers || user.ignoresUsers.length <= 0) {
return [];
}
return Users.getByID.loadMany(user.ignoresUsers);
},
role({ id, role }, _, { user }) {
// If the user is not an admin, only return the current user's roles.
if (user && (user.can(VIEW_USER_ROLE) || user.id === id)) {
return role;
}
return null;
return Users.getByID.loadMany(ignoresUsers);
},
// Extract the reliability from the user metadata if they have permission.
reliable(user, _, { user: requestingUser }) {
if (requestingUser && requestingUser.can(SEARCH_ACTIONS)) {
return KarmaService.model(user);
}
},
reliable: user => KarmaService.model(user),
state(user, args, ctx) {
if (
ctx.user &&
(ctx.user.id === user.id || ctx.user.can(VIEW_USER_STATUS))
) {
return user;
}
},
// The state requires the whole user object to make decisions.
state: user => user,
// Get the first email on the user.
email: property('firstEmail'),
};
// Decorate the User type resolver with a tags field.
decorateWithTags(User);
// decorate the fields on the User resolver with a permission check where the
// current user can also get their own properties.
decorateWithPermissionCheck(
User,
{
actions: [SEARCH_ACTIONS],
email: [VIEW_USER_EMAIL],
state: [VIEW_USER_STATUS],
role: [VIEW_USER_ROLE],
ignoredUsers: [SEARCH_OTHER_USERS],
tokens: [LIST_OWN_TOKENS],
profiles: [SEARCH_OTHER_USERS],
comments: [SEARCH_OTHERS_COMMENTS],
},
checkSelfField('id')
);
// Decorate the fields on the User resolver where the current user has no impact
// on the resolvability of a field.
decorateWithPermissionCheck(User, { reliable: [SEARCH_ACTIONS] });
module.exports = User;
+8 -10
View File
@@ -1,14 +1,12 @@
const { decorateWithPermissionCheck, checkSelfField } = require('./util');
const { VIEW_USER_STATUS } = require('../../perms/constants');
const UserState = {
status: (user, args, ctx) => {
if (
ctx.user &&
(ctx.user.id === user.id || ctx.user.can(VIEW_USER_STATUS))
) {
return user.status;
}
},
};
const UserState = {};
decorateWithPermissionCheck(
UserState,
{ status: [VIEW_USER_STATUS] },
checkSelfField('id')
);
module.exports = UserState;
+164 -32
View File
@@ -2,59 +2,167 @@ const {
ADD_COMMENT_TAG,
SEARCH_OTHER_USERS,
} = require('../../perms/constants');
const property = require('lodash/property');
const { property, isBoolean } = require('lodash');
/**
* Decorates the typeResolver with the tags field.
* getResolver will get the resolver from the typeResolver or apply the default
* resolver.
*
* @param {Object} typeResolver the type resolver
* @param {String} field the field name of the resolver we're getting
*/
const decorateWithTags = typeResolver => {
typeResolver.tags = ({ tags = [] }, _, { user }) => {
if (user && user.can(ADD_COMMENT_TAG)) {
return tags;
const getResolver = (typeResolver, field) => {
if (field in typeResolver) {
return typeResolver[field];
}
return property(field);
};
/**
*
* @param {Object} typeResolver the type resolver
* @param {String} field the name of the field being wrapped
* @param {Function} customCheck the function that can return a boolean
* indicating the resolution status
* @param {Function} skipFieldResolver the optional skip resolver that can be used
* skip out from the oldFieldResolver.
*/
const wrapCheck = (
typeResolver,
field,
customCheck,
skipFieldResolver = getResolver(typeResolver, field)
) => {
// Cache the old field resolver. In the event that the check does not return
// with a boolean, we'll use this.
const oldFieldResolver = getResolver(typeResolver, field);
// Override the field resolver on the type resolver with this wrapped
// function.
typeResolver[field] = (obj, args, ctx, info) => {
const decision = customCheck(obj, args, ctx, info);
if (isBoolean(decision)) {
if (decision) {
// The custom check returns a boolean true, so we should execute the
// underlying field resolver (which may just be the old resolver).
return skipFieldResolver(obj, args, ctx, info);
}
// The custom check returns a boolean false, then we should return null,
// because the check explicity said that we weren't allowed to access that
// field.
return null;
}
return tags.filter(t => t.tag.permissions.public);
// The custom check yielded no decision, so we should just fall back to the
// oldFieldResolver.
return oldFieldResolver(obj, args, ctx, info);
};
};
/**
* checkPermissions checks that the current user has all the required
* permissions.
*
* @param {Object} ctx graph context
* @param {Array<String>} permissions permissions that the user must have
*/
const checkPermissions = (ctx, permissions) =>
!ctx.user || !ctx.user.can(...permissions);
/**
* wrapCheckPermissions will wrap a specific field with a permission check.
*
* @param {Object} typeResolver the type resolver
* @param {String} field the field name of the resolver we're wrapping
* @param {Array<String>} permissions array of permissions to check against
* @param {Function} fieldResolver base resolver for the field
*/
const wrapCheckPermissions = (
typeResolver,
field,
permissions,
skipFieldResolver = getResolver(typeResolver, field)
) =>
wrapCheck(
typeResolver,
field,
(obj, args, ctx) => !checkPermissions(ctx, permissions),
skipFieldResolver
);
/**
* decorateWithPermissionCheck will decorate the field resolver with
* permission checks.
*
* @param {Object} typeResolver the type resolver
* @param {Object} protect the object with field -> Array<String> of permissions
* @param {Function} customCheck a function that can return a boolean based on a
* custom check
*/
const decorateWithPermissionCheck = (typeResolver, protect) => {
const decorateWithPermissionCheck = (
typeResolver,
protect,
customCheck = null
) => {
for (const [field, permissions] of Object.entries(protect)) {
let fieldResolver = property(field);
if (field in typeResolver) {
fieldResolver = typeResolver[field];
const baseFieldResolver = getResolver(typeResolver, field);
wrapCheckPermissions(typeResolver, field, permissions, baseFieldResolver);
if (customCheck !== null) {
wrapCheck(typeResolver, field, customCheck, baseFieldResolver);
}
typeResolver[field] = (obj, args, ctx, info) => {
if (!ctx.user || !ctx.user.can(...permissions)) {
return null;
}
return fieldResolver(obj, args, ctx, info);
};
}
};
/**
* decorateUserField will decorate the user field accesses with correct
* permission checks.
* checkSelf will check if the current object is the same as the current user.
*
* @param {String} referenceField the field for the user id to check.
*/
const checkSelfField = referenceField => (obj, args, ctx) => {
if (
ctx.user &&
obj[referenceField] !== null &&
ctx.user.id === obj[referenceField]
) {
return true;
}
};
/**
* wrapCheckSelf wraps a typeResolver with a check for self (if the type is
* referencing the current user).
*
* @param {Object} typeResolver the type resolver
* @param {String} field the field to decorate
* @param {String} referenceField the field to pull the user id from.
* @param {Function} fieldResolver base resolver for the field
*/
const decorateUserField = (typeResolver, field) => {
const wrapCheckSelf = (
typeResolver,
field,
referenceField = field,
fieldResolver = getResolver(typeResolver, field)
) =>
wrapCheck(typeResolver, field, checkSelfField(referenceField), fieldResolver);
/**
* decorateUserField will decorate the user field accesses with correct
* permission checks and will load the user.
*
* @param {Object} typeResolver the type resolver
* @param {String} field the field to decorate
* @param {String} referenceField the field to pull the user id from.
*/
const decorateUserField = (typeResolver, field, referenceField = field) => {
// The default resolver for the user decorator is loading the user by id.
let fieldResolver = (obj, args, ctx) => {
if (!obj[field]) {
if (!obj[referenceField]) {
return null;
}
return ctx.loaders.Users.getByID.load(obj[field]);
return ctx.loaders.Users.getByID.load(obj[referenceField]);
};
// The resolver can be overridden however. This decorator will simply wrap the
@@ -63,16 +171,39 @@ const decorateUserField = (typeResolver, field) => {
fieldResolver = typeResolver[field];
}
typeResolver[field] = (obj, args, ctx, info) => {
if (
!ctx.user ||
obj[field] === null ||
(ctx.user.id !== obj[field] && !ctx.user.can(SEARCH_OTHER_USERS))
) {
return null;
// Wrap the current fieldResolver with the resolver which will return the
// user.
wrapCheckPermissions(
typeResolver,
field,
[SEARCH_OTHER_USERS],
fieldResolver
);
// Wrap the checked resolver with a check to see if the current user is the
// one being retrieved, in which case we should allow it.
wrapCheckSelf(typeResolver, field, referenceField, fieldResolver);
};
/**
* decorateWithTags decorates a typeResolver with a tags getter that will
* sanitize the tag output for users without permission to see non-public
* tags.
*
* @param {Object} typeResolver base type resolver
* @param {Function} fieldResolver the field resolver that gets the tags
*/
const decorateWithTags = (
typeResolver,
fieldResolver = getResolver(typeResolver, 'tags')
) => {
typeResolver.tags = async (obj, args, ctx, info) => {
const tags = await fieldResolver(obj, args, ctx, info);
if (checkPermissions(ctx, [ADD_COMMENT_TAG])) {
return tags;
}
return fieldResolver(obj, args, ctx, info);
return tags.filter(t => t.tag.permissions.public);
};
};
@@ -80,4 +211,5 @@ module.exports = {
decorateUserField,
decorateWithTags,
decorateWithPermissionCheck,
checkSelfField,
};
+48
View File
@@ -0,0 +1,48 @@
const { EventEmitter2 } = require('eventemitter2');
const debug = require('debug')('talk:graph:subscriptions:broker');
const { getPubsub } = require('./pubsub');
/**
* Broker acts as a pubsub client adapter. Any calls to publish will push into
* the PubSub client and the local event emitter.
*/
class Broker extends EventEmitter2 {
constructor(pubsub) {
// Create the underlying event emitter.
super({
wildcard: true, // Allow wildcard listeners.
maxListeners: 0, // Disable maximum for listeners.
});
this.pubsub = pubsub;
}
/**
* Publishes the event out to the pubsub system and to the broker.
*
* @param {String} event the name of the event to publish
* @param {Any} args the
*/
publish(event, ...args) {
debug(`publish:${event}`);
this.pubsub.publish(event, ...args);
this.emit(event, ...args);
}
}
let client = null;
const getBroker = () => {
if (client !== null) {
return client;
}
// Create the new Broker to manage events being published out to
// the pubsub so that we may intercept.
client = new Broker(getPubsub());
debug('created');
return client;
};
module.exports.getBroker = getBroker;
@@ -2,18 +2,18 @@ const { SubscriptionManager } = require('graphql-subscriptions');
const { SubscriptionServer } = require('subscriptions-transport-ws');
const debug = require('debug')('talk:graph:subscriptions');
const pubsub = require('../services/pubsub');
const schema = require('./schema');
const Context = require('./context');
const plugins = require('../services/plugins');
const { getPubsub } = require('./pubsub');
const schema = require('../schema');
const Context = require('../context');
const plugins = require('../../services/plugins');
const { deserializeUser } = require('../services/subscriptions');
const { deserializeUser } = require('../../services/subscriptions');
const setupFunctions = require('./setupFunctions');
const ms = require('ms');
const { KEEP_ALIVE } = require('../config');
const { KEEP_ALIVE } = require('../../config');
const { BASE_PATH } = require('../url');
const { BASE_PATH } = require('../../url');
// Collect all the plugin hooks that should be executed onConnect and
// onDisconnect.
@@ -99,7 +99,7 @@ const createSubscriptionManager = server =>
{
subscriptionManager: new SubscriptionManager({
schema,
pubsub: pubsub.getClient(),
pubsub: getPubsub(),
setupFunctions,
}),
onConnect,
+29
View File
@@ -0,0 +1,29 @@
const { RedisPubSub } = require('graphql-redis-subscriptions');
const { createClient: createRedisClient } = require('../../services/redis');
const debug = require('debug')('talk:graph:subscriptions:pubsub');
/**
* getPubsub returns the pubsub singleton for this instance.
*/
let pubsub = null;
const getPubsub = () => {
if (pubsub !== null) {
return pubsub;
}
// Create the publisher and subscriber redis clients.
const publisher = createRedisClient();
const subscriber = createRedisClient();
// Create the new PubSub client, we only need one per instance of Talk.
pubsub = new RedisPubSub({
publisher,
subscriber,
});
debug('created');
return pubsub;
};
module.exports.getPubsub = getPubsub;
@@ -11,11 +11,11 @@ const {
SUBSCRIBE_ALL_USERNAME_APPROVED,
SUBSCRIBE_ALL_USERNAME_FLAGGED,
SUBSCRIBE_ALL_USERNAME_CHANGED,
} = require('../perms/constants');
} = require('../../perms/constants');
const merge = require('lodash/merge');
const debug = require('debug')('talk:graph:setupFunctions');
const plugins = require('../services/plugins');
const plugins = require('../../services/plugins');
const setupFunctions = {
commentAdded: (options, args, comment, context) => {
+17 -13
View File
@@ -63,7 +63,7 @@ type Token {
}
type UserProfile {
# the id is an identifier for the user profile (email, facebook id, etc)
# The id is an identifier for the user profile (email, facebook id, etc)
id: String!
# name of the provider attached to the authentication mode
@@ -184,13 +184,17 @@ type User {
# Actions completed on the parent.
actions: [Action!]
# the current roles of the user.
# The current roles of the user.
role: USER_ROLES
# the current profiles of the user.
# The current profiles of the user.
profiles: [UserProfile]
# the tags on the user
# The primary email address of the user. Only accessible to Administrators or
# the current user.
email: String
# The tags on the user.
tags: [TagLink!]
# ignored users.
@@ -421,7 +425,7 @@ input CommentCountQuery {
# The URL that the asset is located on.
asset_url: String
# the parent of the comment that we want to retrieve.
# The parent of the comment that we want to retrieve.
parent_id: ID
# comments returned will only be ones which have at least one action of this
@@ -479,13 +483,13 @@ type Comment {
# The body history of the comment.
body_history: [CommentBodyHistory!]!
# the tags on the comment
# The tags on the comment
tags: [TagLink!]
# the user who authored the comment.
# The user who authored the comment.
user: User
# the replies that were made to the comment.
# The replies that were made to the comment.
replies(query: RepliesQuery = {}): CommentConnection!
# replyCount is the number of replies with a depth of 1. Only direct replies
@@ -765,7 +769,7 @@ type Settings {
infoBoxContent: String
# questionBoxEnable will enable the Question Box's content to be visible above
# the comment box.
# The comment box.
questionBoxEnable: Boolean
# questionBoxContent is the content of the Question Box.
@@ -858,7 +862,7 @@ type Asset {
# The date that the asset was created.
created_at: Date
# the tags on the asset
# The tags on the asset
tags: [TagLink!]
# The author(s) of the asset.
@@ -1091,7 +1095,7 @@ input AssetSettingsInput {
moderation: MODERATION_MODE
# questionBoxEnable will enable the Question Boxs' content to be visible above
# the comment box.
# The comment box.
questionBoxEnable: Boolean
# questionBoxContent is the content of the Question Box.
@@ -1167,7 +1171,7 @@ input ModifyTagInput {
item_type: TAGGABLE_ITEM_TYPE!
# asset_id is used when the item_type is `COMMENTS`, the is needed to rectify
# the settings to get the asset specific tags/settings.
# The settings to get the asset specific tags/settings.
asset_id: ID
}
@@ -1225,7 +1229,7 @@ input UpdateSettingsInput {
infoBoxContent: String
# questionBoxEnable will enable the Question Box's content to be visible above
# the comment box.
# The comment box.
questionBoxEnable: Boolean
# questionBoxContent is the content of the Question Box.
+2
View File
@@ -27,6 +27,8 @@ module.exports = {
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
},
testResultsProcessor: process.env.JEST_REPORTER,
moduleNameMapper: {
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
+5
View File
@@ -0,0 +1,5 @@
const jobs = [require('./mailer')];
const process = () => jobs.forEach(job => job());
module.exports = { process };
+147
View File
@@ -0,0 +1,147 @@
const { task } = require('../services/mailer');
const nodemailer = require('nodemailer');
const debug = require('debug')('talk:jobs:mailer');
const Context = require('../graph/context');
const { get } = require('lodash');
const {
SMTP_HOST,
SMTP_USERNAME,
SMTP_PORT,
SMTP_PASSWORD,
SMTP_FROM_ADDRESS,
} = require('../config');
// parseSMTPPort will return the port for SMTP.
const parseSMTPPort = () => {
if (!SMTP_PORT) {
return 25;
}
try {
return parseInt(SMTP_PORT);
} catch (e) {
throw new Error('TALK_SMTP_PORT is not an integer');
}
};
// createTransport will create a new transport.
const createTransport = () => {
const options = {
host: SMTP_HOST,
};
if (SMTP_USERNAME && SMTP_PASSWORD) {
options.auth = {
user: SMTP_USERNAME,
pass: SMTP_PASSWORD,
};
}
// Get the SMTP port.
options.port = parseSMTPPort();
return nodemailer.createTransport(options);
};
// sharedTransport is the transport singleton.
let sharedTransport;
// getTransport will retrieve the mailer transport singleton.
const getTransport = () => {
if (sharedTransport) {
return sharedTransport;
}
// enabled is true when the required configuration is available. When testing
// is enabled, we will be simulating that emails are being sent, because in a
// production system, emails should and would be sent.
// If the transport details aren't available, we will return null as the
// transport.
if (!SMTP_HOST || !SMTP_FROM_ADDRESS) {
return null;
}
// Create the transport.
sharedTransport = createTransport();
return sharedTransport;
};
// getEmailAddress will retrieve the email address to send the message to from
// the job data.
const getEmailAddress = async ({ email, user }) => {
// If the message has a specific email already to sent it to, just assign
// that to the message. If the email does not have an email, and instead has
// a user id, then we should lookup the user with the graph and get their
// email.
if (email) {
return email;
} else {
// Get the user to send the message to.
const ctx = Context.forSystem();
const { data, errors } = await ctx.graphql(
`
query GetUserEmail($user: ID!) {
user(id: $user) {
email
}
}
`,
{ user }
);
if (errors) {
throw errors;
}
const email = get(data, 'user.email');
if (!email) {
throw errors.ErrMissingEmail;
}
return email;
}
};
// processJob will handle new jobs sent via this queue.
const processJob = transport => async ({ id, data }, done) => {
const { message } = data;
// Get the email address from the job data.
message.to = await getEmailAddress(data);
debug(`Starting to send mail for Job[${id}]`);
// Actually send the email.
transport.sendMail(message, err => {
if (err) {
debug(`Failed to send mail for Job[${id}]:`, err);
return done(err);
}
debug(`Finished sending mail for Job[${id}]`);
return done();
});
};
/**
* Start the queue processor for the mailer job.
*/
module.exports = () => {
// Get a transport.
const transport = getTransport();
if (transport === null) {
console.warn(
new Error(
'sending email is not enabled because required configuration is not available'
)
);
return;
}
debug(`Now processing ${task.name} jobs`);
return task.process(processJob(transport));
};
+76
View File
@@ -0,0 +1,76 @@
const Asset = require('../models/asset');
const scraper = require('../services/scraper');
const Assets = require('../services/assets');
const debug = require('debug')('talk:jobs:scraper');
const metascraper = require('metascraper');
/**
* Scrapes the given asset for metadata.
*/
async function scrape(asset) {
return metascraper.scrapeUrl(
asset.url,
Object.assign({}, metascraper.RULES, {
section: $ => $('meta[property="article:section"]').attr('content'),
modified: $ => $('meta[property="article:modified"]').attr('content'),
})
);
}
/**
* Updates an Asset based on scraped asset metadata.
*/
function update(id, meta) {
return Asset.update(
{ id },
{
$set: {
title: meta.title || '',
description: meta.description || '',
image: meta.image ? meta.image : '',
author: meta.author || '',
publication_date: meta.date || '',
modified_date: meta.modified || '',
section: meta.section || '',
scraped: new Date(),
},
}
);
}
module.exports = () => {
debug(`Now processing ${scraper.task.name} jobs`);
scraper.task.process(async (job, done) => {
debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`);
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'));
}
// 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
}]`
);
// 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
);
return done(err);
}
debug(`Finished on Job[${job.id}] for Asset[${job.data.asset_id}]`);
done();
});
};
+8
View File
@@ -0,0 +1,8 @@
const Context = require('../graph/context');
// Attach a new context to the request.
module.exports = (req, res, next) => {
req.context = new Context(req);
next();
};
+8 -1
View File
@@ -1,6 +1,12 @@
const SettingsService = require('../services/settings');
const { BASE_URL, BASE_PATH, MOUNT_PATH, STATIC_URL } = require('../url');
const {
BASE_URL,
BASE_PATH,
MOUNT_PATH,
STATIC_URL,
STATIC_ORIGIN,
} = require('../url');
const { RECAPTCHA_PUBLIC, WEBSOCKET_LIVE_URI } = require('../config');
@@ -15,6 +21,7 @@ const TEMPLATE_LOCALS = {
TALK_RECAPTCHA_PUBLIC: RECAPTCHA_PUBLIC,
LIVE_URI: WEBSOCKET_LIVE_URI,
STATIC_URL,
STATIC_ORIGIN,
},
};
+6
View File
@@ -99,6 +99,12 @@ const SettingSchema = new Schema(
default: 30 * 1000,
},
tags: [TagSchema],
// Additional metadata to let plugins write settings.
metadata: {
default: {},
type: Object,
},
},
{
timestamps: {
+7
View File
@@ -49,5 +49,12 @@ module.exports = {
},
},
},
'firefox-headless': {
desiredCapabilities: {
'moz:firefoxOptions': {
args: ['-headless'],
},
},
},
},
};
+6 -2
View File
@@ -78,6 +78,7 @@
"bcryptjs": "^2.4.3",
"bowser": "^1.7.2",
"brotli-webpack-plugin": "^0.5.0",
"bunyan": "^1.8.12",
"cli-table": "^0.3.1",
"clipboard": "^1.7.1",
"colors": "^1.1.2",
@@ -114,15 +115,17 @@
"graphql-tag": "^1.2.3",
"graphql-tools": "^0.10.1",
"hammerjs": "^2.0.8",
"hard-source-webpack-plugin": "^0.6.0",
"helmet": "3.8.2",
"history": "^3.0.0",
"hjson": "^3.1.1",
"hjson-loader": "^1.0.0",
"immutability-helper": "^2.2.0",
"imports-loader": "^0.7.1",
"inquirer": "^3.2.2",
"inquirer-autocomplete-prompt": "^0.12.1",
"ioredis": "3.1.4",
"joi": "^13.0.0",
"json-loader": "^0.5.7",
"jsonwebtoken": "^8.0.0",
"jwt-decode": "^2.2.0",
"keymaster": "^1.6.2",
@@ -136,7 +139,7 @@
"minimist": "^1.2.0",
"moment": "^2.18.1",
"mongoose": "^4.12.3",
"morgan": "1.9.0",
"morgan": "^1.9.0",
"ms": "^2.0.0",
"murmurhash-js": "^1.0.0",
"node-emoji": "^1.8.1",
@@ -213,6 +216,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
View File
@@ -9,4 +9,5 @@ module.exports = {
VIEW_PROTECTED_SETTINGS: 'VIEW_PROTECTED_SETTINGS',
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
VIEW_USER_ROLE: 'VIEW_USER_ROLE',
VIEW_USER_EMAIL: 'VIEW_USER_EMAIL',
};
+1 -1
View File
@@ -8,11 +8,11 @@ module.exports = (user, perm) => {
case types.SEARCH_ACTIONS:
case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS:
case types.SEARCH_OTHERS_COMMENTS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_COMMENT_STATUS_HISTORY:
case types.VIEW_USER_STATUS:
case types.VIEW_PROTECTED_SETTINGS:
case types.VIEW_USER_ROLE:
case types.VIEW_USER_EMAIL:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
+1
View File
@@ -6,6 +6,7 @@ export {
withEmit,
excludeIf,
withFragments,
withMutation,
withForgotPassword,
withSignIn,
withSignUp,
+1
View File
@@ -0,0 +1 @@
export { default as Action } from 'coral-framework/lib/action';
+35 -3
View File
@@ -4,6 +4,7 @@ const resolve = require('resolve');
const debug = require('debug')('talk:plugins');
const Joi = require('joi');
const amp = require('app-module-path');
const hjson = require('hjson');
const pkg = require('./package.json');
const PLUGINS_JSON = process.env.TALK_PLUGINS_JSON;
@@ -33,7 +34,9 @@ try {
pluginsPath = defaultPlugins;
}
plugins = require(pluginsPath);
// Load/parse the plugin content using hjson.
const pluginContent = fs.readFileSync(pluginsPath, 'utf8');
plugins = hjson.parse(pluginContent);
} catch (err) {
if (err.code === 'ENOENT') {
console.error(
@@ -73,6 +76,7 @@ const hookSchemas = {
onConnect: Joi.func(),
onDisconnect: Joi.func(),
}),
connect: Joi.func().maxArity(1),
};
/**
@@ -293,14 +297,42 @@ class PluginManager {
plugins[section]
);
}
this.deferredHooks = [];
this.ranDeferredHooks = false;
}
/**
* Utility function which combines the Plugins.section and PluginSection.hook
* calls.
*/
get(section, hook) {
return this.section(section).hook(hook);
get(sectionName, hookName) {
return this.section(sectionName).hook(hookName);
}
/**
* Utility function which combines the Plugins.section and PluginSection.hook
* calls and runs them when the `runDeferred` is called.
*/
defer(sectionName, hookName, callback) {
const plugins = this.section(sectionName).hook(hookName);
// If we've already ran the callbacks, then we should run it immediately.
if (this.ranDeferredHooks) {
plugins.forEach(callback);
} else {
this.deferredHooks.push({ plugins, callback });
}
}
/**
* Calls all deferred hooks.
*/
runDeferred() {
this.deferredHooks.forEach(({ plugins, callback }) =>
plugins.forEach(callback)
);
this.ranDeferredHooks = true;
}
/**
@@ -4,10 +4,6 @@ import Main from '../components/Main';
import { connect } from 'plugin-api/beta/client/hocs';
import { bindActionCreators } from 'redux';
import { setView } from '../actions';
import {
setAuthToken,
handleSuccessfulLogin,
} from 'plugin-api/beta/client/actions/auth';
import * as views from '../enums/views';
class MainContainer extends React.Component {
@@ -24,7 +20,6 @@ class MainContainer extends React.Component {
componentDidMount() {
this.resizeHeight();
this.listenToStorageChanges();
}
componentDidUpdate(prevProps) {
@@ -33,40 +28,6 @@ class MainContainer extends React.Component {
}
}
componentWillUnmount() {
this.unlisten();
}
listenToStorageChanges() {
window.addEventListener('storage', this.handleAuth);
}
unlisten() {
window.removeEventListener('storage', this.handleAuth);
}
// External logins store auth data into `auth`, we use it to detect
// a successful sign in.
handleAuth = e => {
if (e.key === 'auth') {
const { err, data } = JSON.parse(e.newValue);
if (err) {
console.error(err);
} else if (data && data.token) {
if (data.user) {
this.props.handleSuccessfulLogin(data.user, data.token);
} else {
this.props.setAuthToken(data.token);
}
this.unlisten();
localStorage.removeItem('auth');
window.close();
} else {
console.error('auth was set, but did not contain a token');
}
}
};
render() {
return <Main onResetView={this.resetView} view={this.props.view} />;
}
@@ -75,8 +36,6 @@ class MainContainer extends React.Component {
MainContainer.propTypes = {
view: PropTypes.string.isRequired,
setView: PropTypes.func.isRequired,
handleSuccessfulLogin: PropTypes.func.isRequired,
setAuthToken: PropTypes.func.isRequired,
};
const mapStateToProps = ({ talkPluginAuth: state }) => ({
@@ -87,8 +46,6 @@ const mapDispatchToProps = dispatch =>
bindActionCreators(
{
setView,
handleSuccessfulLogin,
setAuthToken,
},
dispatch
);
@@ -1,7 +1,3 @@
export const loginWithFacebook = () => (dispatch, _, { rest }) => {
window.open(
`${rest.uri}/auth/facebook`,
'Continue with Facebook',
'menubar=0,resizable=0,width=500,height=500,top=200,left=500'
);
window.location = `${rest.uri}/auth/facebook`;
};
@@ -1,24 +1,31 @@
module.exports = router => {
const { passport, HandleAuthPopupCallback } = require('services/passport');
/**
* Facebook auth endpoint, this will redirect the user immediately to Facebook
* for authorization.
*/
router.get(
'/api/v1/auth/facebook',
passport.authenticate('facebook', {
router.get('/api/v1/auth/facebook', (req, res, next) => {
const {
connectors: { services: { Passport: { passport } } },
} = req.context;
return passport.authenticate('facebook', {
display: 'popup',
authType: 'rerequest',
scope: ['public_profile'],
})
);
})(req, res, next);
});
/**
* Facebook callback endpoint, this will send the user a HTML page designed to
* send back the user credentials upon successful login.
*/
router.get('/api/v1/auth/facebook/callback', (req, res, next) => {
const {
connectors: {
services: { Passport: { passport, HandleAuthPopupCallback } },
},
} = req.context;
// Perform the facebook login flow and pass the data back through the opener.
passport.authenticate(
'facebook',
@@ -1,7 +1,3 @@
export const loginWithGoogle = () => (dispatch, _, { rest }) => {
window.open(
`${rest.uri}/auth/google`,
'Continue with Google',
'menubar=0,resizable=0,width=500,height=500,top=200,left=500'
);
window.location = `${rest.uri}/auth/google`;
};
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,69 @@
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}>
{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,
};
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,118 @@
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 commentID = get(comment, 'id', null);
if (commentID === null) {
ctx.log.debug('could not get comment id');
return;
}
// Execute the graph request.
const reply = await graphql(
schema,
`
query GetAuthorUserMetadata($comment_id: ID!) {
comment(id: $comment_id) {
id
user {
id
notificationSettings {
onFeatured
}
}
}
}
`,
{},
ctx,
{ 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.debug('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 { connectors: { graph: { schema } } } = ctx;
const reply = await graphql(
schema,
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
id
asset {
title
url
}
}
}
`,
{},
ctx,
{ 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}"
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,69 @@
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({ onReply: 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.onReply;
toggle = () => {
this.props.updateNotificationSettings({
onReply: !this.getOnReplySetting(),
});
};
render() {
return (
<Toggle checked={this.getOnReplySetting()} onChange={this.toggle}>
{t('talk-plugin-notifications-category-reply.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,
};
const enhance = compose(
withFragments({
root: gql`
fragment TalkNotificationsCategoryReply_Toggle_root on RootQuery {
me {
notificationSettings {
onReply
}
}
}
`,
})
);
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.onReply === undefined) {
return;
}
const fragment = gql`
fragment TalkNotificationsCategoryReply_User_Fragment on User {
notificationSettings {
onReply
}
}
`;
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
notificationSettings: {
__typename: 'NotificationSettings',
onReply: input.onReply,
},
};
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-reply:
toggle_description: My comment receives a reply
@@ -0,0 +1,124 @@
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');
return;
}
// Execute the graph request.
const reply = await graphql(
schema,
`
query GetAuthorUserMetadata($comment_id: ID!) {
comment(id: $comment_id) {
id
user {
id
notificationSettings {
onReply
}
}
}
}
`,
{},
ctx,
{ comment_id: parentID }
);
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.onReply',
false
);
if (!enabled) {
return;
}
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
ctx.log.debug('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');
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 { connectors: { graph: { schema } } } = ctx;
const reply = await graphql(
schema,
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
id
asset {
title
url
}
user {
username
}
}
}
`,
{},
ctx,
{ 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}`;
return [headline, replier, permalink];
};
const handler = { handle, category: 'reply', event: 'commentAdded', hydrate };
module.exports = {
typeDefs: `
type NotificationSettings {
onReply: Boolean!
}
input NotificationSettingsInput {
onReply: Boolean
}
`,
resolvers: {
NotificationSettings: {
// onReply returns false by default if not specified.
onReply: settings => get(settings, 'onReply', false),
},
},
translations: path.join(__dirname, 'translations.yml'),
notifications: [handler],
};
@@ -0,0 +1,6 @@
en:
talk-plugin-notifications:
categories:
reply:
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,69 @@
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}>
{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,
};
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,151 @@
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');
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 graphql(
schema,
`
query GetAuthorUserMetadata($comment_id: ID!, $author_id: ID!) {
author: user(id: $author_id) {
role
}
comment(id: $comment_id) {
id
user {
id
notificationSettings {
onStaffReply
}
}
}
}
`,
{},
ctx,
{ 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.debug('onStaffReply is false, will not send the notification');
return;
}
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
ctx.log.debug('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.debug('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.debug({ 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 { connectors: { graph: { schema } } } = ctx;
const reply = await graphql(
schema,
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
id
asset {
title
url
}
user {
username
}
}
settings {
organizationName
}
}
`,
{},
ctx,
{ 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}"
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,27 @@
.root {
margin-bottom: 20px;
}
.innerSettings {
padding-left: 12px;
}
.subtitle {
margin: 0;
margin-bottom: 8px;
}
.turnOffButton {
padding: 2px 0;
color: #2099d6;
border-bottom: 1px solid #2099d6;
&:disabled {
color: #e5e5e5;
border-bottom: 1px solid #e5e5e5;
}
}
.notifcationSettingsSlot {
margin-bottom: 3px;
}
@@ -0,0 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
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';
class Settings extends React.Component {
childFactory = el => {
const pluginName = el.type.talkPluginName;
const props = {
indicateOn: () => this.props.indicateOn(pluginName),
indicateOff: () => this.props.indicateOff(pluginName),
};
return React.cloneElement(el, props);
};
render() {
const {
root,
setTurnOffInputFragment,
updateNotificationSettings,
turnOffAll,
turnOffButtonDisabled,
} = this.props;
return (
<IfSlotIsNotEmpty slot="notificationSettings" queryData={{ root }}>
<div className={styles.root}>
<h3>{t('talk-plugin-notifications.settings_title')}</h3>
<h4 className={styles.subtitle}>
{t('talk-plugin-notifications.settings_subtitle')}
</h4>
<div className={styles.innerSettings}>
<Slot
className={styles.notifcationSettingsSlot}
fill="notificationSettings"
queryData={{ root }}
childFactory={this.childFactory}
setTurnOffInputFragment={setTurnOffInputFragment}
updateNotificationSettings={updateNotificationSettings}
/>
<BareButton
className={styles.turnOffButton}
onClick={turnOffAll}
disabled={turnOffButtonDisabled}
>
{t('talk-plugin-notifications.turn_off_all')}
</BareButton>
</div>
</div>
</IfSlotIsNotEmpty>
);
}
}
Settings.propTypes = {
root: PropTypes.object,
indicateOn: PropTypes.func.isRequired,
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
turnOffAll: PropTypes.func.isRequired,
turnOffButtonDisabled: PropTypes.bool.isRequired,
};
export default Settings;

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