mirror of
https://github.com/wassname/talk.git
synced 2026-07-31 12:50:48 +08:00
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:
@@ -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
|
||||
@@ -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
@@ -2,4 +2,5 @@
|
||||
dist
|
||||
docs
|
||||
node_modules
|
||||
public
|
||||
public
|
||||
|
||||
|
||||
+30
-20
@@ -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,4 +1,4 @@
|
||||
# Talk · [](https://circleci.com/gh/coralproject/talk) · [](https://nodesecurity.io/orgs/coralproject/projects/07ce2e4c-99fb-48f8-b50b-69d2d2c081b8) · [](CONTRIBUTING.md#pull-requests)
|
||||
# Talk · [](https://circleci.com/gh/coralproject/talk) · [](https://nodesecurity.io/orgs/coralproject/projects/7bd7d26c-47ed-4a5f-8c4a-b919bf1c2946) · [](CONTRIBUTING.md#pull-requests)
|
||||
|
||||
Online comments are broken. Our open-source commenting platform, Talk, rethinks how moderation, comment display, and conversation function, creating the opportunity for safer, smarter discussions around your work. [Read more about Talk here](https://coralproject.net/products/talk.html).
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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}
|
||||
`,
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
@@ -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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -264,7 +264,7 @@ pre {
|
||||
.toc {
|
||||
a {
|
||||
@extend .coral-link;
|
||||
border-bottom: none;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-6
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { property } = require('lodash');
|
||||
|
||||
const FlagActionSummary = {
|
||||
reason({ group_id }) {
|
||||
return group_id;
|
||||
},
|
||||
reason: property('group_id'),
|
||||
};
|
||||
|
||||
module.exports = FlagActionSummary;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const jobs = [require('./mailer')];
|
||||
|
||||
const process = () => jobs.forEach(job => job());
|
||||
|
||||
module.exports = { process };
|
||||
+147
@@ -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));
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -49,5 +49,12 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'firefox-headless': {
|
||||
desiredCapabilities: {
|
||||
'moz:firefoxOptions': {
|
||||
args: ['-headless'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+6
-2
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
withEmit,
|
||||
excludeIf,
|
||||
withFragments,
|
||||
withMutation,
|
||||
withForgotPassword,
|
||||
withSignIn,
|
||||
withSignUp,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Action } from 'coral-framework/lib/action';
|
||||
+35
-3
@@ -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
Reference in New Issue
Block a user