Compare commits

..
5 Commits
619 changed files with 15395 additions and 25313 deletions
+1 -2
View File
@@ -1,10 +1,9 @@
# excluded because we'll likely need to rebuild this.
node_modules
# most scripts are used during development and testing, not
# scripts are used during development and testing, not
# production.
scripts
!scripts/generateIntrospectionResult.js
# documentation should not be visable in production.
docs
+10 -11
View File
@@ -3,16 +3,15 @@ docs
client/lib
**/*.html
plugins/*
!plugins/talk-plugin-facebook-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-comment-content
!plugins/coral-plugin-facebook-auth
!plugins/coral-plugin-auth
!plugins/coral-plugin-respect
!plugins/coral-plugin-offtopic
!plugins/coral-plugin-like
!plugins/coral-plugin-mod
!plugins/coral-plugin-love
!plugins/coral-plugin-viewing-options
!plugins/coral-plugin-comment-content
!plugins/talk-plugin-permalink
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-featured
node_modules
+10 -13
View File
@@ -5,30 +5,27 @@ dist
npm-debug.log*
dump.rdb
client/coral-framework/graphql/introspection.json
.env
*.cfg
.idea/
*.swp
*.DS_STORE
test/e2e/reports
coverage/
plugins.json
plugins/*
!plugins/talk-plugin-facebook-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-comment-content
!plugins/coral-plugin-facebook-auth
!plugins/coral-plugin-auth
!plugins/coral-plugin-respect
!plugins/coral-plugin-offtopic
!plugins/coral-plugin-like
!plugins/coral-plugin-mod
!plugins/coral-plugin-love
!plugins/coral-plugin-viewing-options
!plugins/coral-plugin-comment-content
!plugins/talk-plugin-permalink
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-featured
**/node_modules/*
@@ -1,13 +1,10 @@
---
title: Contributor's Guide
permalink: /docs/development/contributing/
---
# Contributor's Guide
Welcome! We are very excited that you are interested in contributing to Talk.
This document is a companion to help you approach contributing. If it does not do so, please [let us know how we can improve it](https://github.com/coralproject/talk/issues)!
By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct).
By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
## What should I Contribute?
@@ -35,7 +32,7 @@ Please file issues if:
Coral has adopted an iterative, agile development philosophy. All contributions that make it into the Talk repo should start with a story or this form:
> As a [type of person] I'd like to be able to [do something] so that I can [get some result].
`As a [type of person] I'd like to be able to [do something] so that I can [get some result].`
This exercise does two things:
@@ -56,7 +53,7 @@ We are looking for _documentarians_ to:
* create new / missing sections, and
* take the lead in making sections, or the over all structure better.
Information about how to update docs can be found in our [FAQ]({{ "/docs/faq" | absolute_url }}#how-do-i-contribute-to-these-docs).
Information about how to update docs can be found in our [FAQ](faq.html#how-do-i-contribute-to-these-docs).
If you'd like to discuss a contribution, please [file an issue](https://github.com/coralproject/talk/issues) describing the changes you would like to see.
@@ -64,7 +61,7 @@ If you'd like to discuss a contribution, please [file an issue](https://github.c
Talk's tranlations are stored in `.yml` files [here](https://github.com/coralproject/talk/tree/master/locales).
Translations can be submitted via pull request. If you do not use github, you can use 'en.yml' as a template and [email](https://coralproject.net/contact) the translations to us. We can import it into the repo.
Translations can be submitted via pull request. If you do not use github, you can use 'en.yml' as a template and [email](https://coralproject.net/contact.html) the translations to us. We can import it into the repo.
## I want to contribute but I'm not sure what to do!
@@ -76,7 +73,7 @@ Please visit our product roadmap here: https://www.pivotaltracker.com/n/projects
### Discussion Forum
If you'd like to discuss what we're up to, please visit or [community](https://community.coralproject.net/) or [contact us](https://coralproject.net/contact).
If you'd like to discuss what we're up to, please visit or [community](https://community.coralproject.net/) or [contact us](https://coralproject.net/contact.html).
### Integrations
@@ -85,3 +82,5 @@ Have a favorite analytics engine? Data science service? CMS? Auth platform? Depl
### Favorite Features?
Do you have a favorite feature of an existing platform that's not yet been done in Talk? Sounds like Talk needs that feature.
## Thanks!
+2 -1
View File
@@ -1,4 +1,4 @@
FROM node:8
FROM node:7.10.1
# Create app directory
RUN mkdir -p /usr/src/app
@@ -17,6 +17,7 @@ RUN yarn global add node-gyp && \
yarn install --frozen-lockfile && \
cli plugins reconcile && \
yarn build && \
yarn install --production && \
yarn cache clean
# Ensure the runtime of the container is in production mode.
+275
View File
@@ -0,0 +1,275 @@
## Contents
- [Installation](#installation) - install the application on a machine
- [Via Docker](#installation-from-docker)
- [Via Source](#installation-from-source)
- [Setup](#setup) - setup the application for first use
- [Usage](#usage) - connect the application to a website
# Installation
## Requirements
- Any flavour of Linux, OSX or Windows
- 1GB memory (minimum)
- 5GB storage (minimum)
- [MongoDB](https://www.mongodb.com/) v3.4 or later
- [Redis](https://redis.io/) v3.2 or later
- SSL Certificate
- This application assumes that you will be serving this application in a
production environment, and therefore requires that you serve it behind a
webserver with a valid SSL certificate. This is chosen in order to secure
user's sessions.
## Installation From Docker
We currently support packaging the Talk application via Docker, which automates
the dependency install and asset build process. This is the recommended way to
deploy the application when used in production.
Available as [coralproject/talk](https://hub.docker.com/r/coralproject/talk/) on Docker Hub.
Images are tagged using the following notation:
- `x` (where `x` is the major version number): any minor or patch updates will be included in this. If you're ok getting
new features occasionally and all the bug fixes, this is the tag for you.
- `x.y` (where `y` is the minor version number): any patch updates will be
included with this tag. If you like getting fixes and having features change
only when you want, this is the tag for you. **(recommended)**
- `x.y.z` (where `z` is the patch version): this tag never gets updated, and
essentially freezes your version, this should only be used when you are either
extending Talk or are sure of a specific version you want to freeze.
We provide tags with `*-onbuild` that can be used for easy plugin integration and
acts as a customization endpoint. Instructions are provided in the `PLUGINS.md`
document as to how to use it.
### Requirements
There are some runtime requirements for running Talk for Docker:
- [Docker](https://www.docker.com/) v1.13.0 or later
- [Docker Compose](https://docs.docker.com/compose/) v1.10.0 or later
_Please be sure to check the versions of these requirements. Incorrect versions
of these may lead to unexpected errors!_
### Installing
An example docker-compose.yml:
```yaml
version: '2'
services:
talk:
image: coralproject/talk:1.5
restart: always
ports:
- "5000:5000"
depends_on:
- mongo
- redis
environment:
- TALK_MONGO_URL=mongodb://mongo/talk
- TALK_REDIS_URL=redis://redis
mongo:
image: mongo:3.2
restart: always
volumes:
- mongo:/data/db
redis:
image: redis:3.2
restart: always
volumes:
- redis:/data
volumes:
mongo:
external: false
redis:
external: false
```
At this stage, you should refer to the `README.md` for configuration variables
that are specific to your installation. Some pre-defined fields have been filled
in the above example which are consistent with Docker Compose naming conventions
for [Docker Links](https://docs.docker.com/compose/networking/#links).
### Scaling
If you are interested in splitting apart services, you can simply adjust the
command being executed in the container to optimize for your use case. An
example would be if you wanted to run the API server and the job processor
on different machines. You can achieve this easily with docker compose:
```yaml
version: '2'
services:
talk-api:
image: coralproject/talk:1.5
command: cli serve
restart: always
ports:
- "5000:5000"
depends_on:
- mongo
- redis
environment:
- TALK_MONGO_URL=mongodb://mongo/talk
- TALK_REDIS_URL=redis://redis
talk-jobs:
image: coralproject/talk:1.5
command: cli jobs process
restart: always
ports:
- "5001:5000"
depends_on:
- mongo
- redis
environment:
- TALK_MONGO_URL=mongodb://mongo/talk
- TALK_REDIS_URL=redis://redis
mongo:
image: mongo:3.2
restart: always
volumes:
- mongo:/data/db
redis:
image: redis:3.2
restart: always
volumes:
- redis:/data
volumes:
mongo:
external: false
redis:
external: false
```
Note that the only difference is in the `command` key. From this, you are able
to discretely control which modules are running in order to have the maximum
flexibility when managing your application.
### Running
If you're using docker compose:
```bash
# Start the services using compose
docker-compose up -d
```
If you're using plain docker:
```bash
docker run -d -P coralproject/talk:latest
```
## Installation From Source
This provides information on how to setup the application from source. Note that
this is not recommended for production deploys, but will work for development
and testing purposes.
### Requirements
There are some runtime requirements for running Talk from source:
- [Node](https://nodejs.org/) ~7.8
- [Yarn](https://yarnpkg.com/) ^0.22.0
_Please be sure to check the versions of these requirements. Incorrect versions
of these may lead to unexpected errors!_
### Installing
#### Download
It is highly recommended that you download a released version as the code
available in `master` may not be stable. You can download the latest release
from the [releases page](https://github.com/coralproject/talk/releases).
You can also clone the git repository via:
```bash
git clone https://github.com/coralproject/talk.git
```
#### Building
We now have to install the dependencies and build the static assets.
```bash
# Install package dependancies
yarn
# Build static files
yarn build
```
After you create/modify the `plugins.json` (refer to `PLUGINS.md` for plugin
docs) file, you can re-run the following to install their dependencies:
```bash
# Reconcile plugins
./bin/cli plugins reconcile
# Build static files
yarn build
```
### Running
Refer to the `README.md` file for required configuration variables to add to the
environment.
You can start the server after configuring the server using the command:
```bash
yarn start
```
This will setup the server to serve everything on a single node.js process and
is designed to be used in production.
You can see other scripts we've made available by consulting the `package.json`
file under the `scripts` key including:
- `yarn test` run unit tests
- `yarn e2e` run end to end tests
- `yarn build-watch` watch for changes to client files and build static assets
- `yarn dev-start` watch for changes to server files and reload the server while
also sourcing a `.env` file in your local directory for configuration
# Setup
Once you've installed Talk (either via Docker or source), you still need to
setup the application. If you are unfamiliar with any terminology used in the
setup process, refer to the `TERMINOLOGY.md` document.
## Via Web
If you want to perform your setup via the web, you can navigate to your
installation of Talk at the path `/admin/install`. There you will be asked a
series of questions for your installation.
## Via CLI
If you want to perform your setup through the terminal, you can simply run:
```bash
cli setup
```
And follow the instructions to perform initial setup and create your first user
account.
# Usage
After setup is complete, you can then refer to the `/admin/configure` path to
get the embed code that you can copy/paste onto your blog or website in order to
start using Talk.
_In order for the embed to work correctly, you will need to whitelist the domain
that is allowed to embed your site on the `/admin/configure` page, failure to do
so will result in the comment stream not loading._
+3
View File
@@ -0,0 +1,3 @@
# Talk Plugins
Our documentation for Talk has moved! Utilize [this hyperlink](https://coralproject.github.io/talk/plugins.html) via click or tap to navigate your browser to the new location!
+97 -1
View File
@@ -5,9 +5,105 @@ Online comments are broken. Our open-source Talk tool rethinks how moderation, c
Third party licenses are available via the `/client/3rdpartylicenses.txt`
endpoint when the server is running with built assets.
## Contributing to Talk
See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md).
## Documentation
See our [Talk Documentation & Guides](https://coralproject.github.io/talk/).
### General
See our [Talk Documentation & Guides](https://coralproject.github.io/talk/index.html).
### Plugins
See our guide to using and building [Talk Plugins](https://github.com/coralproject/talk/blob/master/PLUGINS.md).
### Recipes
Recipes are plugin templates provided by the Coral Core team. Developers can use these recipes to build their own plugins. You can find all the Talk recipes here: https://github.com/coralproject/talk-recipes/
## Usage
### Installation
To set up a development environment or build from source, see [INSTALL.md](https://github.com/coralproject/talk/blob/master/INSTALL.md).
To launch a Talk server of your own from your browser without any need to muck about in a terminal or think about engineering concepts, stay tuned. We will launch [our installer](https://github.com/coralproject/talk-install) shortly!
### Configuration
The Talk application looks for the following configuration values either as environment variables:
- `TALK_MONGO_URL` (*required*) - the database connection string for the MongoDB database.
- `TALK_REDIS_URL` (*required*) - the database connection string for the Redis database.
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
available in the format: `<scheme>://<host>` without the path.
- `TALK_JWT_SECRET` (*required*) - a long and cryptographical secure random string which will be used to
sign and verify tokens via a `HS256` algorithm.
- `TALK_JWT_EXPIRY` (_optional_) - the expiry duration (`exp`) for the tokens issued for logged in sessions (Default `1 day`)
- `TALK_JWT_ISSUER` (_optional_) - the issuer (`iss`) claim for login JWT tokens (Default `process.env.TALK_ROOT_URL`)
- `TALK_JWT_AUDIENCE` (_optional_) - the audience (`aud`) claim for login JWT tokens (Default `talk`)
- `TALK_SMTP_EMAIL` (*required for email*) - the address to send emails from using the
SMTP provider.
- `TALK_SMTP_USERNAME` (*required for email*) - username of the SMTP provider you are using.
- `TALK_SMTP_PASSWORD` (*required for email*) - password for the SMTP provider you are using.
- `TALK_SMTP_HOST` (*required for email*) - SMTP host url with format `smtp.domain.com`.
- `TALK_SMTP_PORT` (*required for email*) - SMTP port.
- `TALK_INSTALL_LOCK` (_optional for dynamic setup_) - Defaults to `FALSE`. When `TRUE`, disables the dynamic setup endpoint.
- `TALK_RECAPTCHA_SECRET` (*required for reCAPTCHA support*) - server secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout.
- `TALK_RECAPTCHA_PUBLIC` (*required for reCAPTCHA support*) - client secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout.
- `TALK_PLUGINS_JSON` (_optional_) - used to specify the plugin config via the environment
- `TALK_KEEP_ALIVE` (_optional_) - The keepalive timeout that should be used to send keep alive messages through the websocket to keep the socket alive. (Default `30s`)
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
alternative methods of loading configuration during development.
### Running Migrations
We have a migration tool that can be run using `bin/cli migration run`. This will detect new migrations available and prompt you to backup your database before proceeding with the migration. Migrations are required with major version releases.
### Using Trust
Talk ships with core components we call "Trust". This allows Talk to automate certain actions based on previous user behavior.
Our first feature is the notion of Karma. Talk will automatically pre-moderate comments of users who have a negative karma score. You can [see more how karma works here](/services/karma.js).
## Supported Browsers
### Web
- Chrome: latest 2 versions
- Firefox: latest 2 versions, and most recent extended support version, if any
- Safari: latest 2 versions
- Internet Explorer: IE Edge, 11
### iOS Devices
- iPad
- iPad Pro
- iPhone 7 Plus
- iPhone 7
- iPhone 6 Plus
- iPhone 6
- iPhone 5
### iOS Browsers
- Chrome for iOS: latest version
- Firefox for iOS: latest version
- Safari for iOS: latest version
### Android Devices
- Galaxy S5
- Nexus 5X
- Nexus 6P
### Android Browsers
- Chrome for Android: latest version
- Firefox for Android: latest version
## License
+73
View File
@@ -0,0 +1,73 @@
# Product's Terminology
This is a guide to have a common language to talk about "Talk".
## Definitions
* Site - a top level site, aka nytimes.com
* Section - the section of a site, aka, Politics.
* Subsection - the section of a site, aka, Politics.
* Asset - An article/video/etc identified by URL.
* Embed - Things we put on a asset: comment box, ToS, Stream, etc…
* Stream - All the activity on a certain asset. Container for Comments, actions, user
* Thread - defined by a parent and everything below. All replies to a comment and their replies, etc…
* Comment - a kind of user-generated content submitted by a comment author
* A parent comment has replies to it
* A child comments is a reply to another comment
* A comment can be both a parent comment and a child of another comment
* A top-level comment is a comment that is not a reply to any other comment
* A nth-level comment refers to the number of replies away from the top-level comment
* User - an item to represent a person using Talk. It could be a moderator, reader, etc.
* User Roles:
* Active: some who takes action (logged in or not)
* Passive: some who just reads, no actions performed
* Comment Author: The user who wrote the comment
* Staff Member: someone who works for an organization (tagged for leverage in trust)
* Moderator: someone with the ability to access the moderation queue and perform moderation actions
* Administrator: has the ability to change the setup of their coral space
* Public Profile: information about users shown in public
* Private Profile: information about users shown only to user about themselves
* Protected Profile: information about users that only moderators and admins can see
* Queue - Group of items based on a query, aka - moderation queue
* Target - The item/s on which an action is performed
## Actions
Actions are performed by users on items. Actions themselves are items. This requires two relationships: action on item, and user performs action.
### Flag
* A Flagger(user) performs a Flag
* A Flag is performed on a Comment or a username or profile content
## Moderation Actions and Status
Comments contain a field `status`. As moderation actions are peformed, the status changes.
* Initial status is empty.
* When a moderator Approves, the status is set to 'approved'.
* When a moderator Rejects, the status is set to 'reject'.
### Pre and post moderation
Comments can be set to be premoderated or postmoderated.
Premoderation means that moderation has to occur _before_ a comment is shown on the site:
* New comments are shown in the moderator queues immediately.
* The are not shown to users until (aka in streams) until they are approved by a moderator.
Postmoderation means that comments appear on the site _before_ any moderation action is taken.
* New comments appear in comment streams immediately.
* New comments do not appear in moderation queues unless they are flagged by other users.
### Word lists
* Banned words - words that the site never allows in a comment
* Suspect words - words whose usage needs to be approved by a moderator before being shown in the stream
* Approved words - words that are usually Banned or Suspect sitewide, but approved for use in a specific article stream
+1 -22
View File
@@ -6,7 +6,6 @@ const helmet = require('helmet');
const authentication = require('./middleware/authentication');
const {passport} = require('./services/passport');
const plugins = require('./services/plugins');
const pubsub = require('./services/pubsub');
const i18n = require('./services/i18n');
const enabled = require('debug').enabled;
const errors = require('./errors');
@@ -15,7 +14,6 @@ const apollo = require('graphql-server-express');
const accepts = require('accepts');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const {ROOT_URL} = require('./config');
const app = express();
@@ -75,12 +73,6 @@ app.use('/public', express.static(path.join(__dirname, 'public')));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Set the BASE_URL as the ROOT_URL.
app.locals.BASE_URL = ROOT_URL;
if (app.locals.BASE_URL[app.locals.BASE_URL.length - 1] !== '/') {
app.locals.BASE_URL += '/';
}
//==============================================================================
// PASSPORT MIDDLEWARE
//==============================================================================
@@ -103,19 +95,6 @@ app.use(passport.initialize());
// (if present) the JWT on the request.
app.use('/api', authentication);
const pubsubClient = pubsub.createClientFactory();
// To handle dependancy injection safer, we inject the pubsub handle onto the
// request object.
app.use('/api', (req, res, next) => {
// Attach the pubsub handle to the requests.
req.pubsub = pubsubClient();
// Forward on the request.
next();
});
//==============================================================================
// GraphQL Router
//==============================================================================
@@ -180,7 +159,7 @@ app.use('/', (err, req, res, next) => {
}
i18n.init(req);
if (err instanceof errors.APIError) {
res.status(err.status);
res.render('error', {
+3 -3
View File
@@ -274,7 +274,7 @@ async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote
}
async function createSeedPlugin() {
const pluginsDir = path.resolve(__dirname, '..', 'plugins');
const pluginsDir = path.join(__dirname, 'plugins');
function pluginNameExists(pluginName) {
const pluginNames = fs.readdirSync(pluginsDir);
@@ -321,7 +321,7 @@ async function createSeedPlugin() {
// Creating plugin seed
//==============================================================================
const seedPlugin = path.join(__dirname, 'templates/plugin');
const seedPlugin = path.join(__dirname, 'bin/templates/plugin');
const newPluginPath = path.join(pluginsDir, answers.pluginName);
if (fs.existsSync(seedPlugin)) {
@@ -355,7 +355,7 @@ async function createSeedPlugin() {
// Let's add this to the plugins.json
if (answers.addPluginsJson) {
const pluginsJson = path.resolve(__dirname, '..', 'plugins.json');
const pluginsJson = path.join(dir, 'plugins.json');
fs.readJson(pluginsJson)
.then((j) => {
+5 -13
View File
@@ -12,7 +12,6 @@ const SetupService = require('../services/setup');
const kue = require('../services/kue');
const mongoose = require('../services/mongoose');
const util = require('./util');
const cache = require('../services/cache');
const {createSubscriptionManager} = require('../graph/subscriptions');
const {
PORT
@@ -81,16 +80,12 @@ function normalizePort(val) {
* Event listener for HTTP server "listening" event.
*/
async function onListening() {
// Start the cache instance.
await cache.init();
function onListening() {
let addr = server.address();
let bind = typeof addr === 'string'
? `pipe ${addr}`
: `port ${addr.port}`;
debug(`API Server Listening on ${bind}`);
console.log(`API Server Listening on ${bind}`);
}
/**
@@ -140,21 +135,18 @@ async function startApp(program) {
/**
* Listen on provided port, on all network interfaces.
*/
server.on('error', onError);
server.on('listening', onListening);
server.on('listening', () => {
});
server.listen(port, () => {
// Mount the websocket server if requested.
if (program.websockets) {
debug(`Websocket Server Listening on ${port}`);
console.log(`Websocket Server Listening on ${port}`);
// Mount the subscriptions server on the application server.
createSubscriptionManager(server);
}
});
server.on('error', onError);
server.on('listening', onListening);
}
//==============================================================================
+2 -2
View File
@@ -4,11 +4,11 @@
#
# ```
# en:
# talk-plugin-respect:
# coral-plugin-respect:
# respect: Respect
# respected: Respected
# es:
# talk-plugin-respect:
# coral-plugin-respect:
# respect: Respetar
# respected: Respetado
# ```
+3 -2
View File
@@ -1,6 +1,6 @@
machine:
node:
version: 8
version: 7.10.1
services:
- docker
- redis
@@ -21,8 +21,9 @@ dependencies:
# Install node dependencies.
- yarn --version
- yarn global add node-gyp --force
- yarn
cache_directories:
- ~/.cache/yarn
post:
# Build the static assets.
- yarn build
+1 -1
View File
@@ -11,4 +11,4 @@
"transform-async-to-generator",
"transform-react-jsx"
]
}
}
+7 -14
View File
@@ -1,6 +1,5 @@
import React from 'react';
import {Router, Route, IndexRedirect, IndexRoute} from 'react-router';
import {history} from 'coral-framework/helpers/router';
import {Router, Route, IndexRedirect, browserHistory, Redirect} from 'react-router';
import Configure from 'routes/Configure';
import Dashboard from 'routes/Dashboard';
@@ -15,7 +14,7 @@ const routes = (
<div>
<Route exact path="/admin/install" component={Install}/>
<Route path='/admin' component={Layout}>
<IndexRedirect to='/admin/moderate' />
<IndexRedirect to='/admin/moderate/all' />
<Route path='configure' component={Configure} />
<Route path='stories' component={Stories} />
<Route path='dashboard' component={Dashboard} />
@@ -35,15 +34,10 @@ const routes = (
{/* Moderation Routes */}
<Route path='moderate' component={ModerationLayout}>
<IndexRoute components={Moderation} />
<Route path='all' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='new' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='approved' components={Moderation}>
<Route path='accepted' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='premod' components={Moderation}>
@@ -52,17 +46,16 @@ const routes = (
<Route path='rejected' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path='reported' components={Moderation}>
<Route path='flagged' components={Moderation}>
<Route path=':id' components={Moderation} />
</Route>
<Route path=':id' components={Moderation} />
<Redirect from=':id' to='all/:id' />
<IndexRedirect to='all' />
</Route>
</Route>
</div>
);
const AppRouter = () => <Router history={history} routes={routes}/>;
const AppRouter = () => <Router history={browserHistory} routes={routes} />;
export default AppRouter;
@@ -15,11 +15,33 @@ export const hideShortcutsNote = () => {
return {type: actions.HIDE_SHORTCUTS_NOTE};
};
export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, userId});
export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL});
export const setSortOrder = (order) => ({
type: actions.SET_SORT_ORDER,
order
});
export const changeUserDetailStatuses = (tab) => {
let statuses;
if (tab === 'all') {
statuses = ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'];
} else if (tab === 'rejected') {
statuses = ['REJECTED'];
}
return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses};
};
export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS});
export const toggleSelectCommentInUserDetail = (id, active) => {
return {
type: active ? actions.SELECT_USER_DETAIL_COMMENT : actions.UNSELECT_USER_DETAIL_COMMENT,
id
};
};
export const toggleStorySearch = (active) => ({
type: active ? actions.SHOW_STORY_SEARCH : actions.HIDE_STORY_SEARCH
});
@@ -1,24 +0,0 @@
import * as actions from 'constants/userDetail';
export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, userId});
export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL});
export const changeUserDetailStatuses = (tab) => {
let statuses;
if (tab === 'all') {
statuses = ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'];
} else if (tab === 'rejected') {
statuses = ['REJECTED'];
}
return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses};
};
export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS});
export const toggleSelectCommentInUserDetail = (id, active) => {
return {
type: active ? actions.SELECT_USER_DETAIL_COMMENT : actions.UNSELECT_USER_DETAIL_COMMENT,
id
};
};
@@ -1,7 +1,7 @@
import React, {PropTypes} from 'react';
import styles from './ModerationList.css';
import {Button} from 'coral-ui';
import {menuActionsMap} from '../utils/moderationQueueActionsMap';
import {menuActionsMap} from '../routes/Moderation/helpers/moderationQueueActionsMap';
import t from 'coral-framework/services/i18n';
@@ -1,24 +0,0 @@
.bodyLeave {
position: absolute;
width: 100%;
top: 0;
background-color: white;
opacity: 1.0;
transition: background 400ms, opacity 800ms 1600ms;
pointer-events: none;
}
.bodyLeaveActive {
opacity: 0;
background-color: rgba(255,255,0, 0.2);
}
.bodyEnter {
opacity: 0;
pointer-events: none;
}
.bodyEnterActive {
opacity: 1.0;
transition: opacity 800ms 2400ms;
}
@@ -1,24 +0,0 @@
import React from 'react';
import {murmur3} from 'murmurhash-js';
import {CSSTransitionGroup} from 'react-transition-group';
import styles from './CommentAnimatedEdit.css';
export default ({children, body}) => {
return (
<CSSTransitionGroup
component={'div'}
transitionName={{
enter: styles.bodyEnter,
enterActive: styles.bodyEnterActive,
leave: styles.bodyLeave,
leaveActive: styles.bodyLeaveActive,
}}
transitionEnter={true}
transitionLeave={true}
transitionEnterTimeout={3600}
transitionLeaveTimeout={2800}
>
{React.cloneElement(React.Children.only(children), {key: murmur3(body)})}
</CSSTransitionGroup>
);
};
@@ -1,27 +0,0 @@
import React from 'react';
import Highlighter from 'react-highlight-words';
import Linkify from 'react-linkify';
const linkify = new Linkify();
export default ({suspectWords, bannedWords, body, ...rest}) => {
const links = linkify.getMatches(body);
const linkText = links ? links.map((link) => link.raw) : [];
// since words are checked against word boundaries on the backend,
// should be the behavior on the front end as well.
// currently the highlighter plugin does not support out of the box.
const searchWords = [...suspectWords, ...bannedWords]
.filter((w) => {
return new RegExp(`(^|\\s)${w}(\\s|$)`, 'i').test(body);
})
.concat(linkText);
return (
<Highlighter
{...rest}
searchWords={searchWords}
textToHighlight={body}
/>
);
};
@@ -1,13 +0,0 @@
import React from 'react';
import Linkify from 'react-linkify';
const linkify = new Linkify();
export default ({text, children}) => {
const hasLinks = !!linkify.getMatches(text);
if (!hasLinks) {
return null;
}
return React.Children.only(children);
};
@@ -192,6 +192,7 @@
.minimal {
width: 45px;
min-width: 0;
float: left;
}
.approve__active {
@@ -1,128 +0,0 @@
.root {
display: block;
margin: 0;
border-bottom: 1px solid #e0e0e0;
width: 100%;
transition: all 200ms;
padding: 10px 0px;
min-height: 0;
}
.rootSelected {
background-color: #ecf4ff;
}
.container {
padding: 0px 14px;
}
.story {
font-size: 14px;
margin: 10px 0;
font-weight: 500;
line-height: 1.2;
max-width: 500px;
}
.story > a {
display: inline-block;
color: #063b9a;
text-decoration: none;
font-weight: 500;
letter-spacing: .5px;
margin-left: 10px;
font-size: 13px;
margin-left: 5px;
padding-bottom: 0px;
border-bottom: solid 1px;
line-height: 16px;
}
.bodyContainer {
display: flex;
justify-content: space-between;
font-size: 14px;
line-height: 1.5;
font-weight: 300;
margin-bottom: 8px;
}
.header {
position: relative;
}
.commentType {
position: absolute;
right: 0px;
}
.created {
padding: 5px;
color: #262626;
font-size: 14px;
line-height: 1px;
font-weight: 300;
}
.body {
margin-top: 0px;
flex: 1;
color: black;
max-width: 500px;
word-wrap: break-word;
font-weight: 300;
font-size: 16px;
max-width: 360px;
}
.sideActions {
}
.editedMarker {
font-style: italic;
color: #666;
font-size: 12px;
line-height: 1px;
font-weight: 300;
}
.actions {
display: flex;
justify-content: flex-end;
}
.bulkSelectInput {
cursor: pointer;
}
.external {
font-size: .7em;
text-decoration: none;
color: #063b9a;
cursor: pointer;
font-weight: normal;
margin-left: 10px;
white-space: nowrap;
&:hover {
text-decoration: underline;
opacity: .9;
}
> i {
font-size: 12px;
top: 2px;
position: relative;
}
}
.hasLinks {
color: #f00;
text-align: right;
display: flex;
align-items: center;
> i {
margin-right: 5px;
}
}
@@ -1,151 +0,0 @@
import React, {PropTypes} from 'react';
import {Link} from 'react-router';
import {Icon} from 'coral-ui';
import FlagBox from './FlagBox';
import styles from './UserDetailComment.css';
import CommentType from './CommentType';
import {getActionSummary} from 'coral-framework/utils';
import ActionButton from 'coral-admin/src/components/ActionButton';
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import {getCommentType} from 'coral-admin/src/utils/comment';
import CommentAnimatedEdit from './CommentAnimatedEdit';
import t, {timeago} from 'coral-framework/services/i18n';
class UserDetailComment extends React.Component {
render() {
const {
actions = [],
comment,
viewUserDetail,
suspectWords,
bannedWords,
selected,
toggleSelect,
className,
user,
...props
} = this.props;
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction');
const commentType = getCommentType(comment);
return (
<li
tabIndex={0}
className={cn(className, styles.root, {[styles.rootSelected]: selected})}
>
<div className={styles.container}>
<div className={styles.header}>
<input
className={styles.bulkSelectInput}
type='checkbox'
value={comment.id}
checked={selected}
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
<span className={styles.created}>
{timeago(comment.created_at)}
</span>
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
<CommentType type={commentType} className={styles.commentType}/>
</div>
<div className={styles.story}>
Story: {comment.asset.title}
{<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
</div>
<CommentAnimatedEdit body={comment.body}>
<div className={styles.bodyContainer}>
<p className={styles.body}>
<CommentBodyHighlighter
suspectWords={suspectWords}
bannedWords={bannedWords}
body={comment.body}
/>
{' '}
<a
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
</a>
</p>
<div className={styles.sideActions}>
<IfHasLink text={comment.body}>
<span className={styles.hasLinks}>
<Icon name="error_outline" /> Contains Link
</span>
</IfHasLink>
<div className={styles.actions}>
{actions.map((action, i) => {
const active =
(action === 'REJECT' && comment.status === 'REJECTED') ||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return (
<ActionButton
minimal={true}
key={i}
type={action}
user={user}
status={comment.status}
active={active}
acceptComment={() =>
(comment.status === 'ACCEPTED'
? null
: props.acceptComment({commentId: comment.id}))}
rejectComment={() =>
(comment.status === 'REJECTED'
? null
: props.rejectComment({commentId: comment.id}))}
/>
);
})}
</div>
</div>
</div>
</CommentAnimatedEdit>
</div>
{flagActions && flagActions.length
? <FlagBox
actions={flagActions}
actionSummaries={flagActionSummaries}
viewUserDetail={viewUserDetail}
/>
: null}
</li>
);
}
}
UserDetailComment.propTypes = {
user: PropTypes.object.isRequired,
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
className: PropTypes.string,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
toggleSelect: PropTypes.func,
comment: PropTypes.shape({
body: PropTypes.string.isRequired,
action_summaries: PropTypes.array,
actions: PropTypes.array,
created_at: PropTypes.string.isRequired,
asset: PropTypes.shape({
title: PropTypes.string,
url: PropTypes.string,
id: PropTypes.string
})
})
};
export default UserDetailComment;
@@ -1,7 +1,13 @@
export const TOGGLE_MODAL = 'TOGGLE_MODAL';
export const SINGLE_VIEW = 'SINGLE_VIEW';
export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE';
export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
export const SET_SORT_ORDER = 'MODERATION_SET_SORT_ORDER';
export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES';
export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT';
export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT';
export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS';
export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH';
export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH';
export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE';
@@ -1,7 +0,0 @@
export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL';
export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL';
export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES';
export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT';
export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT';
export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS';
@@ -10,7 +10,6 @@ import SuspendUserDialog from './SuspendUserDialog';
import {toggleModal as toggleShortcutModal} from '../actions/moderation';
import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth';
import {can} from 'coral-framework/services/perms';
import UserDetail from 'coral-admin/src/containers/UserDetail';
class LayoutContainer extends Component {
componentWillMount() {
@@ -58,7 +57,6 @@ class LayoutContainer extends Component {
>
<BanUserDialog />
<SuspendUserDialog />
<UserDetail />
{this.props.children}
</Layout>
);
@@ -1,39 +0,0 @@
import {gql} from 'react-apollo';
import UserDetailComment from '../components/UserDetailComment';
import withFragments from 'coral-framework/hocs/withFragments';
export default withFragments({
comment: gql`
fragment CoralAdmin_UserDetail_comment on Comment {
id
body
created_at
status
asset {
id
title
url
}
action_summaries {
count
... on FlagActionSummary {
reason
}
}
actions {
... on FlagAction {
id
reason
message
user {
id
username
}
}
}
editing {
edited
}
}
`
})(UserDetailComment);
+13 -26
View File
@@ -1,7 +1,7 @@
import update from 'immutability-helper';
import * as notification from 'coral-admin/src/services/notification';
const queues = ['all', 'premod', 'reported', 'approved', 'rejected', 'new'];
const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected'];
const limit = 10;
const ascending = (a, b) => {
@@ -64,35 +64,23 @@ function addCommentToQueue(root, queue, comment, sort) {
return update(root, changes);
}
/**
* getCommentQueues determines in which queues a comment should be placed.
*/
function getCommentQueues(comment) {
const queues = ['all'];
const isFlagged = comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction');
switch(comment.status) {
case 'ACCEPTED':
queues.push('approved');
break;
case 'REJECTED':
if (comment.status === 'ACCEPTED') {
queues.push('accepted');
}
else if (comment.status === 'REJECTED') {
queues.push('rejected');
break;
case 'PREMOD':
}
else if (comment.status === 'PREMOD') {
queues.push('premod');
queues.push('new');
if (isFlagged) {
queues.push('reported');
}
break;
case 'NONE':
queues.push('new');
if (isFlagged) {
queues.push('reported');
}
break;
}
if (
['NONE', 'PREMOD'].indexOf(comment.status) >= 0
&& comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction')
) {
queues.push('flagged');
}
return queues;
}
@@ -110,7 +98,6 @@ function getCommentQueues(comment) {
*/
export function handleCommentChange(root, comment, sort, notify) {
let next = root;
const nextQueues = getCommentQueues(comment);
let notificationShown = false;
+4 -12
View File
@@ -2,10 +2,8 @@ import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import smoothscroll from 'smoothscroll-polyfill';
import EventEmitter from 'eventemitter2';
import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider';
import {getClient} from 'coral-framework/services/client';
import {getClient} from './services/client';
import store from './services/store';
import App from './components/App';
@@ -14,19 +12,13 @@ import 'react-mdl/extra/material.js';
import './graphql';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
const eventEmitter = new EventEmitter();
// TODO: pass redux actions through the emitter.
loadPluginsTranslations();
injectPluginsReducers();
smoothscroll.polyfill();
render(
<EventEmitterProvider eventEmitter={eventEmitter}>
<ApolloProvider client={getClient()} store={store}>
<App />
</ApolloProvider>
</EventEmitterProvider>
<ApolloProvider client={getClient()} store={store}>
<App />
</ApolloProvider>
, document.querySelector('#root')
);
-2
View File
@@ -7,13 +7,11 @@ import install from './install';
import config from './config';
import banUserDialog from './banUserDialog';
import suspendUserDialog from './suspendUserDialog';
import userDetail from './userDetail';
export default {
auth,
banUserDialog,
suspendUserDialog,
userDetail,
assets,
settings,
community,
+24 -1
View File
@@ -1,9 +1,13 @@
import {fromJS} from 'immutable';
import {fromJS, Set} from 'immutable';
import * as actions from '../constants/moderation';
const initialState = fromJS({
singleView: false,
modalOpen: false,
userDetailId: null,
userDetailActiveTab: 'all',
userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'],
userDetailSelectedIds: new Set(),
storySearchVisible: false,
storySearchString: '',
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
@@ -14,6 +18,9 @@ export default function moderation (state = initialState, action) {
switch (action.type) {
case actions.MODERATION_CLEAR_STATE:
return initialState;
case actions.SET_ACTIVE_TAB:
return state
.set('activeTab', action.activeTab);
case actions.TOGGLE_MODAL:
return state
.set('modalOpen', action.open);
@@ -23,6 +30,22 @@ export default function moderation (state = initialState, action) {
case actions.HIDE_SHORTCUTS_NOTE:
return state
.set('shortcutsNoteVisible', 'hide');
case actions.VIEW_USER_DETAIL:
return state.set('userDetailId', action.userId);
case actions.HIDE_USER_DETAIL:
return state
.set('userDetailId', null)
.update('userDetailSelectedIds', (set) => set.clear());
case actions.CLEAR_USER_DETAIL_SELECTIONS:
return state.update('userDetailSelectedIds', (set) => set.clear());
case actions.CHANGE_USER_DETAIL_STATUSES:
return state
.set('userDetailActiveTab', action.tab)
.set('userDetailStatuses', action.statuses);
case actions.SELECT_USER_DETAIL_COMMENT:
return state.update('userDetailSelectedIds', (set) => set.add(action.id));
case actions.UNSELECT_USER_DETAIL_COMMENT:
return state.update('userDetailSelectedIds', (set) => set.delete(action.id));
case actions.SHOW_STORY_SEARCH:
return state.set('storySearchVisible', true);
case actions.HIDE_STORY_SEARCH:
@@ -1,47 +0,0 @@
import * as actions from '../constants/userDetail';
const initialState = {
userId: null,
activeTab: 'all',
statuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'],
selectedCommentIds: [],
};
export default function banUserDialog(state = initialState, action) {
switch (action.type) {
case actions.VIEW_USER_DETAIL:
return {
...state,
userId: action.userId,
};
case actions.HIDE_USER_DETAIL:
return {
...state,
userId: null,
selectedCommentIds: [],
};
case actions.CLEAR_USER_DETAIL_SELECTIONS:
return {
...state,
selectedCommentIds: [],
};
case actions.CHANGE_USER_DETAIL_STATUSES:
return {
...state,
activeTab: action.tab,
statuses: action.statuses,
};
case actions.SELECT_USER_DETAIL_COMMENT:
return {
...state,
selectedCommentIds: [...state.selectedCommentIds, action.id],
};
case actions.UNSELECT_USER_DETAIL_COMMENT:
return {
...state,
selectedCommentIds: state.selectedCommentIds.filter((id) => id !== action.id),
};
default:
return state;
}
}
@@ -1,7 +1,7 @@
import React from 'react';
import styles from './Community.css';
import {Button} from 'coral-ui';
import {menuActionsMap} from '../../../utils/moderationQueueActionsMap';
import {menuActionsMap} from '../../Moderation/helpers/moderationQueueActionsMap';
import t from 'coral-framework/services/i18n';
@@ -52,6 +52,57 @@
}
}
.email {
display: block;
}
.dataTable {
width: 100%;
border-left: none;
border-right: none;
th {
font-size: 1.1em;
}
th:nth-child(2), th:nth-child(3) {
width: 100px;
}
}
.selectField {
position: relative;
width: 150px;
height: 36px;
background: #2c2c2c;
padding: 10px 15px;
box-sizing: border-box;
color: white;
border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
> div {
padding: 0;
}
i {
position: absolute;
top: 7px;
right: 7px;
}
input {
padding: 0;
font-size: 13px;
letter-spacing: 0.7px;
font-weight: 400;
}
&:hover {
cursor: pointer;
}
}
.list {
padding: 8px 0;
list-style: none;
@@ -289,11 +340,3 @@ p.flaggedByReason {
margin: 0px;
line-height: 1.4;
}
.button {
composes: buttonReset from 'coral-framework/styles/reset.css';
vertical-align: text-bottom;
&:hover {
background-color: #E0E0E0;
}
}
@@ -54,7 +54,7 @@ export default class Community extends Component {
}
getTabContent(searchValue, props) {
const {community, root: {users}, viewUserDetail} = props;
const {community, root: {users}} = props;
const activeTab = props.route.path === ':id' ? 'flagged' : props.route.path;
if (activeTab === 'people') {
@@ -84,7 +84,6 @@ export default class Community extends Component {
approveUser={props.approveUser}
rejectUsername={props.rejectUsername}
currentUser={this.props.currentUser}
viewUserDetail={viewUserDetail}
/>
<RejectUsernameDialog
open={community.rejectUsernameDialog}
@@ -5,7 +5,7 @@ import styles from './Community.css';
import EmptyCard from 'coral-admin/src/components/EmptyCard';
import User from './User';
const FlaggedAccounts = (props) => {
const FlaggedAccounts = ({...props}) => {
const {commenters} = props;
const hasResults = commenters && !!commenters.length;
@@ -25,7 +25,6 @@ const FlaggedAccounts = (props) => {
showRejectUsernameDialog={props.showRejectUsernameDialog}
approveUser={props.approveUser}
currentUser={props.currentUser}
viewUserDetail={props.viewUserDetail}
/>;
})
: <EmptyCard>{t('community.no_flagged_accounts')}</EmptyCard>
@@ -1,58 +0,0 @@
.dataTable {
width: 100%;
border-left: none;
border-right: none;
th {
font-size: 1.1em;
}
th:nth-child(2), th:nth-child(3) {
width: 100px;
}
}
.button {
composes: buttonReset from 'coral-framework/styles/reset.css';
&:hover {
background-color: #D0D0D0;
}
}
.email {
display: block;
}
.selectField {
position: relative;
width: 150px;
height: 36px;
background: #2c2c2c;
padding: 10px 15px;
box-sizing: border-box;
color: white;
border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
> div {
padding: 0;
}
i {
position: absolute;
top: 7px;
right: 7px;
}
input {
padding: 0;
font-size: 13px;
letter-spacing: 0.7px;
font-weight: 400;
}
&:hover {
cursor: pointer;
}
}
@@ -1,9 +1,9 @@
import React from 'react';
import {SelectField, Option} from 'react-mdl-selectfield';
import styles from '../components/Table.css';
import styles from '../components/Community.css';
import t from 'coral-framework/services/i18n';
export default ({headers, commenters, onHeaderClickHandler, onRoleChange, onCommenterStatusChange, viewUserDetail}) => (
export default ({headers, commenters, onHeaderClickHandler, onRoleChange, onCommenterStatusChange}) => (
<table className={`mdl-data-table ${styles.dataTable}`}>
<thead>
<tr>
@@ -21,7 +21,7 @@ export default ({headers, commenters, onHeaderClickHandler, onRoleChange, onComm
{commenters.map((row, i)=> (
<tr key={i}>
<td className="mdl-data-table__cell--non-numeric">
<button onClick={() => {viewUserDetail(row.id);}} className={styles.button}>{row.username}</button>
{row.username}
<span className={styles.email}>{row.profiles.map(({id}) => id)}</span>
</td>
<td className="mdl-data-table__cell--non-numeric">
@@ -2,7 +2,7 @@ import React from 'react';
import styles from './Community.css';
import ActionButton from './ActionButton';
import {username} from 'talk-plugin-flags/helpers/flagReasons';
import {username} from 'coral-plugin-flags/helpers/flagReasons';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
@@ -38,7 +38,9 @@ const User = (props) => {
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
<button onClick={() => {props.viewUserDetail(user.id);}} className={styles.button}>{user.username}</button>
<span>
{user.username}
</span>
{props.currentUser.id !== user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
@@ -80,12 +82,7 @@ const User = (props) => {
(action, j) => {
if (action.reason === action_sum.reason) {
return <p className={styles.flaggedByReason} key={j}>
{action.user &&
<button onClick={() => {props.viewUserDetail(action.user.id);}} className={styles.button}>
{action.user.username}
</button>
}
: {action.message ? action.message : 'n/a'}
{action.user && action.user.username}: {action.message ? action.message : 'n/a'}
</p>;
}
return null;
@@ -17,7 +17,6 @@ import {
showRejectUsernameDialog,
hideRejectUsernameDialog
} from '../../../actions/community';
import {viewUserDetail} from '../../../actions/userDetail';
import Community from '../components/Community';
@@ -100,7 +99,6 @@ const mapDispatchToProps = (dispatch) =>
hideRejectUsernameDialog,
updateSorting,
newPage,
viewUserDetail,
}, dispatch);
export default compose(
@@ -4,7 +4,6 @@ import {bindActionCreators} from 'redux';
import {compose} from 'react-apollo';
import {setRole, setCommenterStatus} from '../../../actions/community';
import Table from '../components/Table';
import {viewUserDetail} from '../../../actions/userDetail';
class TableContainer extends Component {
@@ -30,7 +29,6 @@ const mapDispatchToProps = (dispatch) =>
bindActionCreators({
setCommenterStatus,
setRole,
viewUserDetail,
}, dispatch);
export default compose(
@@ -2,7 +2,6 @@ import React, {Component} from 'react';
import t from 'coral-framework/services/i18n';
import styles from './Configure.css';
import {Button, Card} from 'coral-ui';
import {BASE_URL} from 'coral-framework/constants/url';
class EmbedLink extends Component {
@@ -25,14 +24,21 @@ class EmbedLink extends Component {
}
render () {
const coralJsUrl = [BASE_URL, '/embed.js'].join('');
const location = window.location;
const talkBaseUrl = [
location.protocol,
'//',
location.hostname,
location.port ? (`:${window.location.port}`) : ''
].join('');
const coralJsUrl = [talkBaseUrl, '/embed.js'].join('');
const nonce = String(Math.random()).slice(2);
const streamElementId = `coral_talk_${nonce}`;
const embedText = `
<div id="${streamElementId}"></div>
<script src="${coralJsUrl}" async onload="
Coral.Talk.render(document.getElementById('${streamElementId}'), {
talk: '${BASE_URL}'
talk: '${talkBaseUrl}'
});
"></script>
`.trim();
@@ -1,20 +1,22 @@
import React, {PropTypes} from 'react';
import {Link} from 'react-router';
import Linkify from 'react-linkify';
import {Icon} from 'coral-ui';
import FlagBox from 'coral-admin/src/components/FlagBox';
import FlagBox from './FlagBox';
import styles from './styles.css';
import CommentType from 'coral-admin/src/components/CommentType';
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
import CommentType from './CommentType';
import Highlighter from 'react-highlight-words';
import Slot from 'coral-framework/components/Slot';
import {getActionSummary} from 'coral-framework/utils';
import ActionButton from 'coral-admin/src/components/ActionButton';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import {getCommentType} from 'coral-admin/src/utils/comment';
import {murmur3} from 'murmurhash-js';
import {CSSTransitionGroup} from 'react-transition-group';
const linkify = new Linkify();
import t, {timeago} from 'coral-framework/services/i18n';
@@ -27,16 +29,41 @@ class Comment extends React.Component {
viewUserDetail,
suspectWords,
bannedWords,
minimal,
selected,
toggleSelect,
className,
...props
} = this.props;
const links = linkify.getMatches(comment.body);
const linkText = links ? links.map((link) => link.raw) : [];
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction');
const commentType = getCommentType(comment);
const flagActions =
comment.actions &&
comment.actions.filter((a) => a.__typename === 'FlagAction');
let commentType = '';
if (comment.status === 'PREMOD') {
commentType = 'premod';
} else if (flagActions && flagActions.length) {
commentType = 'flagged';
}
let selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
// since words are checked against word boundaries on the backend,
// should be the behavior on the front end as well.
// currently the highlighter plugin does not support out of the box.
const searchWords = [...suspectWords, ...bannedWords]
.filter((w) => {
return new RegExp(`(^|\\s)${w}(\\s|$)`, 'i').test(comment.body);
})
.concat(linkText);
let selectionStateCSS;
if (minimal) {
selectionStateCSS = selected ? styles.minimalSelection : '';
} else {
selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
}
const showSuspenUserDialog = () => props.showSuspendUserDialog({
userId: comment.user.id,
@@ -54,21 +81,31 @@ class Comment extends React.Component {
return (
<li
tabIndex={0}
className={cn(className, 'mdl-card', selectionStateCSS, styles.Comment, styles.listItem, {[styles.selected]: selected})}
tabIndex={props.index}
className={cn(className, 'mdl-card', selectionStateCSS, styles.Comment, styles.listItem, {[styles.minimal]: minimal, [styles.selected]: selected})}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
{
(
!minimal && (
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
{comment.user.username}
</span>
)
}
{
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
<input
className={styles.bulkSelectInput}
type='checkbox'
value={comment.id}
checked={selected}
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
)
}
<span className={styles.created}>
{timeago(comment.created_at)}
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
</span>
{
(comment.editing && comment.editing.edited)
@@ -88,31 +125,45 @@ class Comment extends React.Component {
</ActionsMenuItem>
</ActionsMenu>
}
<div className={styles.adminCommentInfoBar}>
<CommentType type={commentType} className={styles.commentType}/>
<Slot
data={props.data}
root={props.root}
comment={comment}
asset={comment.asset}
fill="adminCommentInfoBar"
/>
</div>
<CommentType type={commentType} />
</div>
{comment.user.status === 'banned'
? <span className={styles.banned}>
<Icon name="error_outline" />
{t('comment.banned_user')}
</span>
: null}
<Slot
data={props.data}
root={props.root}
fill="adminCommentInfoBar"
comment={comment}
/>
</div>
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!props.currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
<Link to={`/admin/moderate/all/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
</div>
<CommentAnimatedEdit body={comment.body}>
<div className={styles.itemBody}>
<CSSTransitionGroup
component={'div'}
style={{position: 'relative'}}
transitionName={{
enter: styles.bodyEnter,
enterActive: styles.bodyEnterActive,
leave: styles.bodyLeave,
leaveActive: styles.bodyLeaveActive,
}}
transitionEnter={true}
transitionLeave={true}
transitionEnterTimeout={3600}
transitionLeaveTimeout={2800}
>
<div className={styles.itemBody} key={murmur3(comment.body)}>
<p className={styles.body}>
<CommentBodyHighlighter
suspectWords={suspectWords}
bannedWords={bannedWords}
body={comment.body}
<Highlighter
searchWords={searchWords}
textToHighlight={comment.body}
/>
{' '}
<a
@@ -130,11 +181,11 @@ class Comment extends React.Component {
comment={comment}
/>
<div className={styles.sideActions}>
<IfHasLink text={comment.body}>
<span className={styles.hasLinks}>
<Icon name="error_outline" /> Contains Link
</span>
</IfHasLink>
{links
? <span className={styles.hasLinks}>
<Icon name="error_outline" /> Contains Link
</span>
: null}
<div className={`actions ${styles.actions}`}>
{actions.map((action, i) => {
const active =
@@ -142,6 +193,7 @@ class Comment extends React.Component {
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return (
<ActionButton
minimal={minimal}
key={i}
type={action}
user={comment.user}
@@ -167,7 +219,7 @@ class Comment extends React.Component {
/>
</div>
</div>
</CommentAnimatedEdit>
</CSSTransitionGroup>
</div>
<Slot
data={props.data}
@@ -179,7 +231,7 @@ class Comment extends React.Component {
? <FlagBox
actions={flagActions}
actionSummaries={flagActionSummaries}
viewUserDetail={viewUserDetail}
viewUserDetail={() => viewUserDetail(comment.user.id)}
/>
: null}
</li>
@@ -188,6 +240,7 @@ class Comment extends React.Component {
}
Comment.propTypes = {
minimal: PropTypes.bool,
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
@@ -198,6 +251,7 @@ Comment.propTypes = {
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
currentUserId: PropTypes.string.isRequired,
toggleSelect: PropTypes.func,
comment: PropTypes.shape({
body: PropTypes.string.isRequired,
action_summaries: PropTypes.array,
@@ -1,18 +1,22 @@
.commentType {
display: inline-block;
position: absolute;
right: 15px;
top: 11px;
color: white;
background: grey;
height: 32px;
box-sizing: border-box;
padding: 2px 8px;
line-height: 29px;
padding: 2px 8px 2px 26px;
border-radius: 2px;
font-size: 12px;
height: 28px;
> i {
i {
font-size: 14px;
vertical-align: text-top;
position: absolute;
left: 6px;
top: 8px;
margin: 0;
margin-right: 4px;
}
&.premod {
@@ -1,13 +1,12 @@
import React, {PropTypes} from 'react';
import styles from './CommentType.css';
import {Icon} from 'coral-ui';
import cn from 'classnames';
const CommentType = (props) => {
const typeData = getTypeData(props.type);
return (
<span className={cn(styles.commentType, styles[typeData.className], props.className)}>
<span className={`${styles.commentType} ${styles[typeData.className]}`}>
<Icon name={typeData.icon}/>{typeData.text}
</span>
);
@@ -61,7 +61,7 @@ class FlagBox extends Component {
<ul>
{actionList.map((action, j) =>
<li key={`${i}_${j}`} className={styles.subDetail}>
<a className={styles.username} onClick={() => viewUserDetail(action.user.id)}>
<a className={styles.username} onClick={viewUserDetail}>
{action.user.username}
</a>
{action.message}
@@ -6,8 +6,8 @@ import ModerationQueue from './ModerationQueue';
import ModerationMenu from './ModerationMenu';
import ModerationHeader from './ModerationHeader';
import ModerationKeysModal from '../../../components/ModerationKeysModal';
import UserDetail from '../containers/UserDetail';
import StorySearch from '../containers/StorySearch';
import Slot from 'coral-framework/components/Slot';
export default class Moderation extends Component {
constructor() {
@@ -59,7 +59,8 @@ export default class Moderation extends Component {
}
getComments = () => {
const {root, activeTab} = this.props;
const {root, route} = this.props;
const activeTab = route.path === ':id' ? 'premod' : route.path;
return root[activeTab].nodes;
}
@@ -101,28 +102,25 @@ export default class Moderation extends Component {
}
render () {
const {root, data, moderation, settings, viewUserDetail, hideUserDetail, activeTab, getModPath, premodEnabled, ...props} = this.props;
const {root, moderation, settings, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props;
const assetId = this.props.params.id;
const {asset} = root;
const comments = root[activeTab];
let activeTabCount;
switch(activeTab) {
case 'all':
activeTabCount = root.allCount;
break;
case 'new':
activeTabCount = root.newCount;
break;
case 'approved':
activeTabCount = root.approvedCount;
case 'accepted':
activeTabCount = root.acceptedCount;
break;
case 'premod':
activeTabCount = root.premodCount;
break;
case 'reported':
activeTabCount = root.reportedCount;
case 'flagged':
activeTabCount = root.flaggedCount;
break;
case 'rejected':
activeTabCount = root.rejectedCount;
@@ -140,16 +138,12 @@ export default class Moderation extends Component {
<ModerationMenu
asset={asset}
allCount={root.allCount}
newCount={root.newCount}
getModPath={getModPath}
approvedCount={root.approvedCount}
acceptedCount={root.acceptedCount}
premodCount={root.premodCount}
rejectedCount={root.rejectedCount}
reportedCount={root.reportedCount}
flaggedCount={root.flaggedCount}
selectSort={this.props.setSortOrder}
sort={this.props.moderation.sortOrder}
premodEnabled={premodEnabled}
activeTab={activeTab}
/>
<ModerationQueue
data={this.props.data}
@@ -179,20 +173,24 @@ export default class Moderation extends Component {
open={moderation.modalOpen}
onClose={this.onClose}/>
{moderation.userDetailId && (
<UserDetail
id={moderation.userDetailId}
hideUserDetail={hideUserDetail}
bannedWords={settings.wordlist.banned}
suspectWords={settings.wordlist.suspect}
showBanUserDialog={props.showBanUserDialog}
showSuspendUserDialog={props.showSuspendUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment} />
)}
<StorySearch
assetId={assetId}
moderation={this.props.moderation}
closeSearch={this.closeSearch}
storySearchChange={this.props.storySearchChange}
/>
<Slot
data={data}
root={root}
assset={asset}
activeTab={activeTab}
fill='adminModeration'
/>
</div>
);
}
@@ -4,72 +4,52 @@ import styles from './styles.css';
import {SelectField, Option} from 'react-mdl-selectfield';
import {Icon} from 'coral-ui';
import {Link} from 'react-router';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
const ModerationMenu = ({
asset = {},
allCount,
approvedCount,
premodCount,
newCount,
rejectedCount,
reportedCount,
selectSort,
sort,
premodEnabled,
getModPath,
activeTab
}) => {
const ModerationMenu = (
{asset, allCount, acceptedCount, premodCount, rejectedCount, flaggedCount, selectSort, sort}
) => {
function getPath (type) {
return asset ? `/admin/moderate/${type}/${asset.id}` : `/admin/moderate/${type}`;
}
return (
<div className="mdl-tabs">
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<div className={styles.tabBarPadding} />
<div>
{
premodEnabled ? (
<Link
to={getModPath('premod', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'premod'})}
activeClassName={styles.active}>
<Icon name='access_time' className={styles.tabIcon} /> {t('modqueue.premod')} <CommentCount count={premodCount} />
</Link>
) : (
<Link
to={getModPath('new', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'new'})}
activeClassName={styles.active}>
<Icon name='question_answer' className={styles.tabIcon} /> {t('modqueue.new')} <CommentCount count={newCount} />
</Link>
)
}
<Link
to={getModPath('reported', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'reported'})}
activeClassName={styles.active}>
<Icon name='flag' className={styles.tabIcon} /> {t('modqueue.reported')} <CommentCount count={reportedCount} />
</Link>
<Link
to={getModPath('approved', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'approved'})}
activeClassName={styles.active}>
<Icon name='check' className={styles.tabIcon} /> {t('modqueue.approved')} <CommentCount count={approvedCount} />
</Link>
<Link
to={getModPath('rejected', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'rejected'})}
activeClassName={styles.active}>
<Icon name='close' className={styles.tabIcon} /> {t('modqueue.rejected')} <CommentCount count={rejectedCount} />
</Link>
<Link
to={getModPath('all', asset.id)}
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'all'})}
to={getPath('all')}
className={`mdl-tabs__tab ${styles.tab}`}
activeClassName={styles.active}>
<Icon name='question_answer' className={styles.tabIcon} /> {t('modqueue.all')} <CommentCount count={allCount} />
</Link>
<Link
to={getPath('premod')}
className={`mdl-tabs__tab ${styles.tab}`}
activeClassName={styles.active}>
<Icon name='access_time' className={styles.tabIcon} /> {t('modqueue.premod')} <CommentCount count={premodCount} />
</Link>
<Link
to={getPath('flagged')}
className={`mdl-tabs__tab ${styles.tab}`}
activeClassName={styles.active}>
<Icon name='flag' className={styles.tabIcon} /> {t('modqueue.flagged')} <CommentCount count={flaggedCount} />
</Link>
<Link
to={getPath('accepted')}
className={`mdl-tabs__tab ${styles.tab}`}
activeClassName={styles.active}>
<Icon name='check' className={styles.tabIcon} /> {t('modqueue.approved')} <CommentCount count={acceptedCount} />
</Link>
<Link
to={getPath('rejected')}
className={`mdl-tabs__tab ${styles.tab}`}
activeClassName={styles.active}>
<Icon name='close' className={styles.tabIcon} /> {t('modqueue.rejected')} <CommentCount count={rejectedCount} />
</Link>
</div>
<SelectField
className={styles.selectField}
@@ -88,7 +68,7 @@ ModerationMenu.propTypes = {
allCount: PropTypes.number.isRequired,
premodCount: PropTypes.number.isRequired,
rejectedCount: PropTypes.number.isRequired,
reportedCount: PropTypes.number.isRequired,
flaggedCount: PropTypes.number.isRequired,
asset: PropTypes.shape({
id: PropTypes.string
})
@@ -3,7 +3,7 @@ import React, {PropTypes} from 'react';
import Comment from '../containers/Comment';
import styles from './styles.css';
import EmptyCard from '../../../components/EmptyCard';
import {actionsMap} from '../../../utils/moderationQueueActionsMap';
import {actionsMap} from '../helpers/moderationQueueActionsMap';
import LoadMore from './LoadMore';
import t from 'coral-framework/services/i18n';
import {CSSTransitionGroup} from 'react-transition-group';
@@ -85,6 +85,7 @@ class ModerationQueue extends React.Component {
data={this.props.data}
root={this.props.root}
key={comment.id}
index={i}
comment={comment}
selected={i === selectedIndex}
suspectWords={props.suspectWords}
@@ -74,7 +74,7 @@
cursor: pointer;
display: block;
text-decoration: none;
min-height: 50px;
height: 50px;
box-sizing: border-box;
transition: background-color 400ms;
@@ -64,7 +64,6 @@ const StorySearch = (props) => {
? <Spinner />
: assets.map((story, i) => {
const storyOpen = story.closedAt === null || new Date(story.closedAt) > new Date();
return <Story
key={i}
id={story.id}
@@ -1,20 +1,22 @@
import React, {PropTypes} from 'react';
import Comment from './UserDetailComment';
import Comment from './Comment';
import styles from './UserDetail.css';
import {Button, Drawer, Spinner} from 'coral-ui';
import {Button, Drawer} from 'coral-ui';
import {Slot} from 'coral-framework/components';
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
import {actionsMap} from '../utils/moderationQueueActionsMap';
import {actionsMap} from '../helpers/moderationQueueActionsMap';
import ClickOutside from 'coral-framework/components/ClickOutside';
export default class UserDetail extends React.Component {
static propTypes = {
userId: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
bannedWords: PropTypes.array.isRequired,
suspectWords: PropTypes.array.isRequired,
showBanUserDialog: PropTypes.func.isRequired,
showSuspendUserDialog: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
changeStatus: PropTypes.func.isRequired,
@@ -43,17 +45,7 @@ export default class UserDetail extends React.Component {
this.props.changeStatus('rejected');
}
renderLoading() {
return (
<ClickOutside onClickOutside={this.props.hideUserDetail}>
<Drawer onClose={this.props.hideUserDetail}>
<Spinner />
</Drawer>
</ClickOutside>
);
}
renderLoaded() {
render () {
const {
root: {
user,
@@ -61,17 +53,19 @@ export default class UserDetail extends React.Component {
rejectedComments,
comments: {nodes}
},
activeTab,
selectedCommentIds,
moderation: {
userDetailActiveTab: tab,
userDetailSelectedIds: selectedIds
},
bannedWords,
suspectWords,
toggleSelect,
bulkAccept,
bulkReject,
hideUserDetail,
viewUserDetail,
showBanUserDialog,
showSuspendUserDialog,
hideUserDetail
} = this.props;
const localProfile = user.profiles.find((p) => p.provider === 'local');
let profile;
@@ -119,11 +113,11 @@ export default class UserDetail extends React.Component {
</div>
</div>
{
selectedCommentIds.length === 0
selectedIds.length === 0
? (
<ul className={styles.commentStatuses}>
<li className={activeTab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
<li className={activeTab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
<li className={tab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
<li className={tab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
</ul>
)
: (
@@ -140,30 +134,34 @@ export default class UserDetail extends React.Component {
cStyle='reject'
icon='close'>
</Button>
{`${selectedCommentIds.length} comments selected`}
{`${selectedIds.length} comments selected`}
</div>
)
}
<div>
{
nodes.map((comment) => {
nodes.map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
const selected = selectedCommentIds.indexOf(comment.id) !== -1;
const selected = selectedIds.indexOf(comment.id) !== -1;
return <Comment
key={comment.id}
user={user}
index={i}
comment={comment}
selected={false}
suspectWords={suspectWords}
bannedWords={bannedWords}
viewUserDetail={() => {}}
actions={actionsMap[status]}
showBanUserDialog={showBanUserDialog}
showSuspendUserDialog={showSuspendUserDialog}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
selected={selected}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
/>;
currentAsset={null}
currentUserId={this.props.id}
minimal={true} />;
})
}
</div>
@@ -171,11 +169,4 @@ export default class UserDetail extends React.Component {
</ClickOutside>
);
}
render () {
if (this.props.loading) {
return this.renderLoading();
}
return this.renderLoaded();
}
}
@@ -213,12 +213,11 @@ span {
.author {
font-weight: 300;
width: 100%;
min-width: 230px;
display: flex;
align-items: center;
color: #262626;
font-size: 16px;
position: relative;
}
}
@@ -458,6 +457,18 @@ span {
}
}
.minimal {
margin: 0;
}
.minimalSelection {
background-color: #ecf4ff;
}
.bulkSelectInput {
cursor: pointer;
}
.emptyCardContainer {
margin-top: 16px;
}
@@ -480,6 +491,31 @@ span {
opacity: 1.0;
}
.bodyLeave {
position: absolute;
width: 100%;
top: 0;
background-color: white;
opacity: 1.0;
transition: background 400ms, opacity 800ms 1600ms;
pointer-events: none;
}
.bodyLeaveActive {
opacity: 0;
background-color: rgba(255,255,0, 0.2);
}
.bodyEnter {
opacity: 0;
pointer-events: none;
}
.bodyEnterActive {
opacity: 1.0;
transition: opacity 800ms 2400ms;
}
.editedMarker {
font-style: italic;
color: #666;
@@ -492,11 +528,3 @@ span {
position: relative;
top: .3em;
}
.adminCommentInfoBar {
min-width: 100px;
position: absolute;
right: 0px;
top: 0px;
text-align: right;
}
@@ -1,21 +1,22 @@
import {gql} from 'react-apollo';
import Comment from '../components/Comment';
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
import withFragments from 'coral-framework/hocs/withFragments';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
const slots = [
const pluginFragments = getSlotsFragments([
'adminCommentInfoBar',
'adminCommentContent',
'adminSideActions',
'adminCommentDetailArea',
];
]);
export default withFragments({
root: gql`
fragment CoralAdmin_ModerationComment_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
${pluginFragments.spreads('root')}
}
${pluginFragments.definitions('root')}
`,
comment: gql`
fragment CoralAdmin_ModerationComment_comment on Comment {
@@ -41,11 +42,9 @@ export default withFragments({
}
actions {
... on FlagAction {
id
reason
message
user {
id
username
}
}
@@ -53,7 +52,8 @@ export default withFragments({
editing {
edited
}
${getSlotFragmentSpreads(slots, 'comment')}
${pluginFragments.spreads('comment')}
}
${pluginFragments.definitions('comment')}
`
})(Comment);
@@ -8,7 +8,6 @@ import t from 'coral-framework/services/i18n';
import update from 'immutability-helper';
import truncate from 'lodash/truncate';
import NotFoundAsset from '../components/NotFoundAsset';
import {isPremod, getModPath} from '../../../utils';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {handleCommentChange} from '../../../graphql/utils';
@@ -16,12 +15,13 @@ import {handleCommentChange} from '../../../graphql/utils';
import {fetchSettings} from 'actions/settings';
import {showBanUserDialog} from 'actions/banUserDialog';
import {showSuspendUserDialog} from 'actions/suspendUserDialog';
import {viewUserDetail} from '../../../actions/userDetail';
import {
toggleModal,
singleView,
hideShortcutsNote,
toggleStorySearch,
viewUserDetail,
hideUserDetail,
setSortOrder,
storySearchChange,
clearState
@@ -38,18 +38,7 @@ function prepareNotificationText(text) {
class ModerationContainer extends Component {
subscriptions = [];
get activeTab() {
const {root: {asset, settings}, router, route} = this.props;
// Grab premod from asset or from settings
const premod = !router.params.id ? settings.moderation : asset.settings.moderation;
const queue = isPremod(premod) ? 'premod' : 'new';
const activeTab = route.path && route.path !== ':id' ? route.path : queue;
return activeTab;
}
get activeTab() { return this.props.route.path; }
subscribeToUpdates(variables = this.props.data.variables) {
const sub1 = this.props.data.subscribeToMore({
@@ -165,16 +154,13 @@ class ModerationContainer extends Component {
case 'all':
variables.statuses = null;
break;
case 'new':
variables.statuses = ['NONE', 'PREMOD'];
break;
case 'approved':
case 'accepted':
variables.statuses = ['ACCEPTED'];
break;
case 'premod':
variables.statuses = ['PREMOD'];
break;
case 'reported':
case 'flagged':
variables.statuses = ['NONE', 'PREMOD'];
variables.action_type = 'FLAG';
break;
@@ -199,7 +185,7 @@ class ModerationContainer extends Component {
};
render () {
const {root, root: {asset, settings}, data, params: {id: assetId}} = this.props;
const {root, root: {asset}, data, params: {id: assetId}} = this.props;
if (data.error) {
return <div>Error</div>;
@@ -224,12 +210,10 @@ class ModerationContainer extends Component {
return <Moderation
{...this.props}
getModPath={getModPath}
loadMore={this.loadMore}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
activeTab={this.activeTab}
premodEnabled={assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation)}
/>;
}
}
@@ -321,14 +305,7 @@ const withModQueueQuery = withQuery(gql`
}) {
...CoralAdmin_Moderation_CommentConnection
}
new: comments(query: {
statuses: [NONE, PREMOD],
asset_id: $asset_id,
sort: $sort
}) {
...CoralAdmin_Moderation_CommentConnection
}
approved: comments(query: {
accepted: comments(query: {
statuses: [ACCEPTED],
asset_id: $asset_id,
sort: $sort
@@ -342,7 +319,7 @@ const withModQueueQuery = withQuery(gql`
}) {
...CoralAdmin_Moderation_CommentConnection
}
reported: comments(query: {
flagged: comments(query: {
action_type: FLAG,
asset_id: $asset_id,
statuses: [NONE, PREMOD],
@@ -361,18 +338,11 @@ const withModQueueQuery = withQuery(gql`
id
title
url
settings {
moderation
}
}
allCount: commentCount(query: {
asset_id: $asset_id
})
newCount: commentCount(query: {
statuses: [NONE, PREMOD],
asset_id: $asset_id
})
approvedCount: commentCount(query: {
acceptedCount: commentCount(query: {
statuses: [ACCEPTED],
asset_id: $asset_id
})
@@ -384,14 +354,13 @@ const withModQueueQuery = withQuery(gql`
statuses: [REJECTED],
asset_id: $asset_id
})
reportedCount: commentCount(query: {
flaggedCount: commentCount(query: {
action_type: FLAG,
asset_id: $asset_id,
statuses: [NONE, PREMOD]
})
settings {
organizationName
moderation
}
}
${commentConnectionFragment}
@@ -412,11 +381,7 @@ const withQueueCountPolling = withQuery(gql`
allCount: commentCount(query: {
asset_id: $asset_id
})
newCount: commentCount(query: {
statuses: [NONE, PREMOD],
asset_id: $asset_id
})
approvedCount: commentCount(query: {
acceptedCount: commentCount(query: {
statuses: [ACCEPTED],
asset_id: $asset_id
})
@@ -428,7 +393,7 @@ const withQueueCountPolling = withQuery(gql`
statuses: [REJECTED],
asset_id: $asset_id
})
reportedCount: commentCount(query: {
flaggedCount: commentCount(query: {
action_type: FLAG,
asset_id: $asset_id,
statuses: [NONE, PREMOD]
@@ -461,6 +426,7 @@ const mapDispatchToProps = (dispatch) => ({
toggleStorySearch,
showSuspendUserDialog,
viewUserDetail,
hideUserDetail,
setSortOrder,
storySearchChange,
clearState
@@ -59,13 +59,13 @@ class StorySearchContainer extends React.Component {
goToStory = (id) => {
const {router} = this.props;
router.push(`/admin/moderate/${id}`);
router.push(`/admin/moderate/all/${id}`);
this.clearAndCloseSearch();
}
goToModerateAll = () => {
const {router} = this.props;
router.push('/admin/moderate');
router.push('/admin/moderate/all');
this.clearAndCloseSearch();
}
@@ -1,41 +1,44 @@
import React from 'react';
import React, {PropTypes} from 'react';
import {compose, gql} from 'react-apollo';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import UserDetail from '../components/UserDetail';
import withQuery from 'coral-framework/hocs/withQuery';
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
import {getDefinitionName} from 'coral-framework/utils';
import {
viewUserDetail,
hideUserDetail,
changeUserDetailStatuses,
clearUserDetailSelections,
toggleSelectCommentInUserDetail,
} from 'coral-admin/src/actions/userDetail';
toggleSelectCommentInUserDetail
} from 'coral-admin/src/actions/moderation';
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
import UserDetailComment from './UserDetailComment';
import Comment from './Comment';
const commentConnectionFragment = gql`
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
nodes {
...${getDefinitionName(UserDetailComment.fragments.comment)}
...${getDefinitionName(Comment.fragments.comment)}
}
hasNextPage
startCursor
endCursor
}
${UserDetailComment.fragments.comment}
${Comment.fragments.comment}
`;
const slots = [
const pluginFragments = getSlotsFragments([
'userProfile',
];
]);
class UserDetailContainer extends React.Component {
static propTypes = {
id: PropTypes.string.isRequired,
hideUserDetail: PropTypes.func.isRequired
}
// status can be 'ACCEPTED' or 'REJECTED'
bulkSetCommentStatus = (status) => {
const changes = this.props.selectedCommentIds.map((commentId) => {
const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => {
return this.props.setCommentStatus({commentId, status});
});
@@ -53,29 +56,16 @@ class UserDetailContainer extends React.Component {
this.bulkSetCommentStatus('ACCEPTED');
}
acceptComment = ({commentId}) => {
return this.props.setCommentStatus({commentId, status: 'ACCEPTED'});
}
rejectComment = ({commentId}) => {
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
}
render () {
if (!this.props.userId) {
if (!('user' in this.props.root)) {
return null;
}
const loading = !('user' in this.props.root) || this.props.root.user.id !== this.props.userId;
return <UserDetail
bulkReject={this.bulkReject}
bulkAccept={this.bulkAccept}
changeStatus={this.props.changeUserDetailStatuses}
toggleSelect={this.props.toggleSelectCommentInUserDetail}
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
loading={loading}
{...this.props} />;
}
}
@@ -90,7 +80,7 @@ export const withUserDetailQuery = withQuery(gql`
id
provider
}
${getSlotFragmentSpreads(slots, 'user')}
${pluginFragments.spreads('user')}
}
totalComments: commentCount(query: {author_id: $author_id})
rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
@@ -100,34 +90,29 @@ export const withUserDetailQuery = withQuery(gql`
}) {
...CoralAdmin_Moderation_CommentConnection
}
${getSlotFragmentSpreads(slots, 'root')}
${pluginFragments.spreads('root')}
}
${Comment.fragments.comment}
${pluginFragments.definitions('user')}
${pluginFragments.definitions('root')}
${commentConnectionFragment}
`, {
options: ({userId, statuses}) => {
options: ({id, moderation: {userDetailStatuses: statuses}}) => {
return {
variables: {author_id: userId, statuses}
variables: {author_id: id, statuses}
};
},
skip: (ownProps) => !ownProps.userId,
}
});
const mapStateToProps = (state) => ({
userId: state.userDetail.userId,
selectedCommentIds: state.userDetail.selectedCommentIds,
statuses: state.userDetail.statuses,
activeTab: state.userDetail.activeTab,
bannedWords: state.settings.toJS().wordlist.banned,
suspectWords: state.settings.toJS().wordlist.suspect,
moderation: state.moderation.toJS()
});
const mapDispatchToProps = (dispatch) => ({
...bindActionCreators({
changeUserDetailStatuses,
clearUserDetailSelections,
toggleSelectCommentInUserDetail,
viewUserDetail,
hideUserDetail,
toggleSelectCommentInUserDetail
}, dispatch)
});
@@ -0,0 +1,6 @@
import {getClient as getFrameworkClient} from 'coral-framework/services/client';
import fragmentMatcher from './fragmentMatcher';
export function getClient() {
return getFrameworkClient({fragmentMatcher});
}
@@ -0,0 +1,65 @@
import {IntrospectionFragmentMatcher} from 'apollo-client';
// TODO this is a short-term fix
// we need to set up something to query the server for the schema before ApolloClient initialization
// https://github.com/apollographql/apollo-client/issues/1555#issuecomment-295834774
const fm = new IntrospectionFragmentMatcher({
introspectionQueryResultData: {
__schema: {
types: [
{
kind: 'INTERFACE',
name: 'UserError',
possibleTypes: [
{name: 'GenericUserError'},
{name: 'ValidationUserError'}
]
},
{
kind: 'INTERFACE',
name: 'Response',
possibleTypes: [
{name: 'CreateCommentResponse'},
{name: 'CreateFlagResponse'},
{name: 'CreateDontAgreeResponse'},
{name: 'DeleteActionResponse'},
{name: 'SetUserStatusResponse'},
{name: 'SuspendUserResponse'},
{name: 'SetCommentStatusResponse'},
{name: 'ModifyTagResponse'},
{name: 'IgnoreUserResponse'},
{name: 'StopIgnoringUserResponse'}
]
},
{
kind: 'INTERFACE',
name: 'Action',
possibleTypes: [
{name: 'DefaultAction'},
{name: 'FlagAction'},
{name: 'DontAgreeAction'}
]
},
{
kind: 'INTERFACE',
name: 'ActionSummary',
possibleTypes: [
{name: 'DefaultActionSummary'},
{name: 'FlagActionSummary'},
{name: 'DontAgreeActionSummary'}
]
},
{
kind: 'INTERFACE',
name: 'AssetActionSummary',
possibleTypes: [
{name: 'DefaultAssetActionSummary'},
{name: 'FlagAssetActionSummary'}
]
}
]
}
}
});
export default fm;
+1 -1
View File
@@ -1,7 +1,7 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from '../reducers';
import {getClient} from 'coral-framework/services/client';
import {getClient} from './client';
const middlewares = [
applyMiddleware(getClient().middleware()),
-9
View File
@@ -1,9 +0,0 @@
export function getCommentType(comment) {
let commentType = '';
if (comment.status === 'PREMOD') {
commentType = 'premod';
} else if (comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction')) {
commentType = 'flagged';
}
return commentType;
}
-4
View File
@@ -1,4 +0,0 @@
export const isPremod = (mod) => mod === 'PRE';
export const getModPath = (type = 'all', assetId) =>
assetId ? `/admin/moderate/${type}/${assetId}` : `/admin/moderate/${type}`;
+2 -3
View File
@@ -1,6 +1,5 @@
import React from 'react';
import {Router, Route} from 'react-router';
import {history} from 'coral-framework/helpers/router';
import {Router, Route, browserHistory} from 'react-router';
import Embed from './containers/Embed';
import {LoginContainer} from 'coral-sign-in/containers/LoginContainer';
@@ -12,6 +11,6 @@ const routes = (
</div>
);
const AppRouter = () => <Router history={history} routes={routes} />;
const AppRouter = () => <Router history={browserHistory} routes={routes} />;
export default AppRouter;
+20 -31
View File
@@ -1,24 +1,35 @@
import pym from 'coral-framework/services/pym';
import * as actions from '../constants/stream';
import {buildUrl} from 'coral-framework/utils';
import queryString from 'query-string';
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
function removeParam(key, sourceURL) {
let rtn = sourceURL.split('?')[0];
let param;
let params_arr = [];
let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : '';
if (queryString !== '') {
params_arr = queryString.split('&');
for (let i = params_arr.length - 1; i >= 0; i -= 1) {
param = params_arr[i].split('=')[0];
if (param === key) {
params_arr.splice(i, 1);
}
}
rtn = `${rtn}?${params_arr.join('&')}`;
}
return rtn;
}
export const viewAllComments = () => {
const search = queryString.stringify({
...queryString.parse(location.search),
comment_id: undefined,
});
// remove the comment_id url param
const url = buildUrl({...location, search});
const modifiedUrl = removeParam('comment_id', location.href);
try {
// "window" here refers to the embedded iframe
window.history.replaceState({}, document.title, url);
window.history.replaceState({}, document.title, modifiedUrl);
// also change the parent url
pym.sendMessage('coral-view-all-comments');
@@ -27,28 +38,6 @@ export const viewAllComments = () => {
return {type: actions.VIEW_ALL_COMMENTS};
};
export const viewComment = (id) => {
const search = queryString.stringify({
...queryString.parse(location.search),
comment_id: id,
});
// remove the comment_id url param
const url = buildUrl({...location, search});
try {
// "window" here refers to the embedded iframe
window.history.replaceState({}, document.title, url);
// also change the parent url
pym.sendMessage('coral-view-comment', id);
} catch (e) { /* not sure if we're worried about old browsers */ }
return {type: actions.VIEW_COMMENT, id};
};
export const addCommentClassName = (className) => ({
type: actions.ADD_COMMENT_CLASSNAME,
className
@@ -125,6 +125,8 @@ class AllCommentsPane extends React.Component {
root,
comments,
commentClassNames,
addTag,
removeTag,
ignoreUser,
setActiveReplyBox,
activeReplyBox,
@@ -171,6 +173,8 @@ class AllCommentsPane extends React.Component {
currentUser={currentUser}
postFlag={postFlag}
postDontAgree={postDontAgree}
addTag={addTag}
removeTag={removeTag}
ignoreUser={ignoreUser}
commentIsIgnored={commentIsIgnored}
loadMore={loadNewReplies}
@@ -16,8 +16,9 @@
}
.comment {
position: relative;
padding-left: 15px;
display: flex;
flex-flow: row;
}
.commentLevel0 {
@@ -66,6 +67,17 @@
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}
/* element in the top right of the Comment */
.topRight {
float: right;
margin-top: 10px;
text-align: right;
}
.topRight > * {
text-align: initial;
}
.topRight .popover {
margin-top: 1em;
right: 0px;
@@ -77,6 +89,11 @@
border-bottom: 2px solid currentColor;
}
.topRightMenu {
cursor: pointer;
margin-top: 5px;
}
.editCommentForm {
margin-bottom: 10px;
}
@@ -127,7 +144,7 @@
}
.commentInfoBar {
margin-left: auto;
float: right;
}
@keyframes enter {
@@ -141,6 +158,7 @@
}
.commentContainer {
flex: auto;
}
.commentAvatar {
@@ -155,19 +173,3 @@
vertical-align: middle;
font-size: 14px;
}
.commentFooter {
padding-top: 8px;
}
.header {
display: flex;
align-items: center;
}
.content {
}
.footer {
min-height: 10px;
}
@@ -1,11 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import React, {PropTypes} from 'react';
import AuthorName from 'talk-plugin-author-name/AuthorName';
import TagLabel from 'talk-plugin-tag-label/TagLabel';
import PubDate from 'talk-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'talk-plugin-replies';
import {FlagComment} from 'talk-plugin-flags';
import AuthorName from 'coral-plugin-author-name/AuthorName';
import TagLabel from 'coral-plugin-tag-label/TagLabel';
import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
import {FlagComment} from 'coral-plugin-flags';
import {can} from 'coral-framework/services/perms';
import {TransitionGroup} from 'react-transition-group';
import cn from 'classnames';
@@ -14,15 +13,21 @@ import {THREADING_LEVEL} from '../constants/stream';
import merge from 'lodash/merge';
import mapValues from 'lodash/mapValues';
import {
BestButton,
IfUserCanModifyBest,
BEST_TAG,
commentIsBest,
BestIndicator
} from 'coral-plugin-best/BestButton';
import LoadMore from './LoadMore';
import {getEditableUntilDate} from './util';
import {TopRightMenu} from './TopRightMenu';
import CommentContent from './CommentContent';
import Slot from 'coral-framework/components/Slot';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import InactiveCommentLabel from './InactiveCommentLabel';
import {EditableCommentContent} from './EditableCommentContent';
import {getActionSummary, iPerformedThisAction, forEachError, isCommentActive} from 'coral-framework/utils';
import {getActionSummary, iPerformedThisAction, forEachError} from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
@@ -173,13 +178,19 @@ export default class Comment extends React.Component {
}).isRequired,
// given a comment, return whether it should be rendered as ignored
commentIsIgnored: PropTypes.func,
commentIsIgnored: React.PropTypes.func,
// dispatch action to add a tag to a comment
addTag: React.PropTypes.func,
// dispatch action to remove a tag from a comment
removeTag: React.PropTypes.func,
// dispatch action to ignore another user
ignoreUser: PropTypes.func,
ignoreUser: React.PropTypes.func,
// edit a comment, passed (id, asset_id, { body })
editComment: PropTypes.func,
editComment: React.PropTypes.func,
}
editComment = (...args) => {
@@ -292,8 +303,6 @@ export default class Comment extends React.Component {
render () {
const {
asset,
data,
root,
depth,
comment,
postFlag,
@@ -312,16 +321,14 @@ export default class Comment extends React.Component {
addNotification,
charCountEnable,
showSignInDialog,
addTag,
removeTag,
liveUpdates,
commentIsIgnored,
commentClassNames = []
} = this.props;
const view = this.getVisibileReplies();
// Inactive comments can be viewed by moderators and admins (e.g. using permalinks).
const isActive = isCommentActive(comment.status);
const {loadingState} = this.state;
const isPending = comment.id.indexOf('pending') >= 0;
const isHighlighted = highlighted === comment.id;
@@ -340,6 +347,40 @@ export default class Comment extends React.Component {
myFlag = dontAgreeSummary.find((s) => s.current_user);
}
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
const notifyOnError = (fn, errorToMessage) =>
async function(...args) {
if (typeof errorToMessage !== 'function') {
errorToMessage = (error) => error.message;
}
try {
return await fn(...args);
} catch (error) {
addNotification('error', errorToMessage(error));
throw error;
}
};
const addBestTag = notifyOnError(
() =>
addTag({
id: comment.id,
name: BEST_TAG,
assetId: asset.id,
}),
() => 'Failed to tag comment as best'
);
const removeBestTag = notifyOnError(
() =>
removeTag({
id: comment.id,
name: BEST_TAG,
assetId: asset.id,
}),
() => 'Failed to remove best comment tag'
);
/**
* conditionClassNames
* adds classNames based on condition
@@ -383,15 +424,6 @@ export default class Comment extends React.Component {
}
);
// props that are passed down the slots.
const slotProps = {
data,
root,
asset,
comment,
depth,
};
return (
<div
className={rootClassName}
@@ -402,182 +434,196 @@ export default class Comment extends React.Component {
<Slot
className={styles.commentAvatar}
fill="commentAvatar"
{...slotProps}
comment={comment}
commentId={comment.id}
data={this.props.data}
root={this.props.root}
inline
/>
<div className={styles.commentContainer}>
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
<div className={styles.header}>
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
{commentIsBest(comment)
? <TagLabel><BestIndicator /></TagLabel>
: null }
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
</span>
<Slot
className={styles.commentInfoBar}
fill="commentInfoBar"
depth={depth}
comment={comment}
commentId={comment.id}
data={this.props.data}
root={this.props.root}
inline
/>
{ (currentUser && (comment.user.id === currentUser.id)) &&
/* User can edit/delete their own comment for a short window after posting */
<span className={cn(styles.topRight)}>
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
commentIsStillEditable(comment) &&
<a
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
onClick={this.onClickEdit}>Edit</a>
}
</span>
}
{ (currentUser && (comment.user.id !== currentUser.id)) &&
<Slot
className={styles.commentInfoBar}
fill="commentInfoBar"
{...slotProps}
/>
{ 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)}>
{
commentIsStillEditable(comment) &&
<a
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
onClick={this.onClickEdit}>Edit</a>
}
</span>
}
{ isActive && (currentUser && (comment.user.id !== currentUser.id)) &&
/* TopRightMenu allows currentUser to ignore other users' comments */
<span className={cn(styles.topRight, styles.topRightMenu)}>
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification} />
</span>
}
{ !isActive &&
<InactiveCommentLabel status={comment.status}/>
}
</div>
<div className={styles.content}>
{
this.state.isEditing
? <EditableCommentContent
editComment={this.editComment}
addNotification={addNotification}
/* TopRightMenu allows currentUser to ignore other users' comments */
<span className={cn(styles.topRight, styles.topRightMenu)}>
<TopRightMenu
comment={comment}
currentUser={currentUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
parentId={parentId}
stopEditing={this.stopEditing}
/>
: <div>
<Slot
fill="commentContent"
defaultComponent={CommentContent}
{...slotProps}
ignoreUser={ignoreUser}
addNotification={addNotification} />
</span>
}
{
this.state.isEditing
? <EditableCommentContent
editComment={this.editComment}
addNotification={addNotification}
comment={comment}
currentUser={currentUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
parentId={parentId}
stopEditing={this.stopEditing}
/>
</div>
}
</div>
<div className={cn(styles.footer, 'talk-stream-comment-footer')}>
{isActive &&
<div className={'talk-stream-comment-actions-container'}>
<div className="commentActionsLeft comment__action-container">
<Slot
fill="commentReactions"
{...slotProps}
inline
/>
{!disableReply &&
<ActionButton>
<ReplyButton
onClick={this.showReplyBox}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
/>
</ActionButton>}
</div>
<div className="commentActionsRight comment__action-container">
<Slot
fill="commentActions"
wrapperComponent={ActionButton}
{...slotProps}
inline
/>
<ActionButton>
<FlagComment
flaggedByCurrentUser={!!myFlag}
flag={myFlag}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
addNotification={addNotification}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser}
/>
</ActionButton>
</div>
: <div>
<Slot fill="commentContent" comment={comment} defaultComponent={CommentContent} />
</div>
}
}
<div className="commentActionsLeft comment__action-container">
<Slot
fill="commentReactions"
data={this.props.data}
root={this.props.root}
comment={comment}
commentId={comment.id}
inline
/>
<ActionButton>
<IfUserCanModifyBest user={currentUser}>
<BestButton
isBest={commentIsBest(comment)}
addBest={addBestTag}
removeBest={removeBestTag}
/>
</IfUserCanModifyBest>
</ActionButton>
{!disableReply &&
<ActionButton>
<ReplyButton
onClick={this.showReplyBox}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
/>
</ActionButton>}
</div>
<div className="commentActionsRight comment__action-container">
<Slot
fill="commentActions"
wrapperComponent={ActionButton}
data={this.props.data}
root={this.props.root}
asset={asset}
comment={comment}
commentId={comment.id}
inline
/>
<ActionButton>
<FlagComment
flaggedByCurrentUser={!!myFlag}
flag={myFlag}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
addNotification={addNotification}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser}
/>
</ActionButton>
</div>
</div>
</div>
{activeReplyBox === comment.id
? <ReplyBox
commentPostedHandler={() => {
setActiveReplyBox('');
}}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
setActiveReplyBox={setActiveReplyBox}
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
addNotification={addNotification}
postComment={postComment}
currentUser={currentUser}
assetId={asset.id}
/>
: null}
<TransitionGroup>
{view.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
loadMore={loadMore}
ignoreUser={ignoreUser}
{activeReplyBox === comment.id
? <ReplyBox
commentPostedHandler={() => {
setActiveReplyBox('');
}}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
showSignInDialog={showSignInDialog}
commentIsIgnored={commentIsIgnored}
liveUpdates={liveUpdates}
reactKey={reply.id}
key={reply.id}
comment={reply}
/>;
})}
</TransitionGroup>
<div className="talk-load-more-replies">
<LoadMore
topLevel={false}
replyCount={moreRepliesCount}
moreComments={hasMoreComments}
loadMore={this.loadNewReplies}
loadingState={loadingState}
/>
setActiveReplyBox={setActiveReplyBox}
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
addNotification={addNotification}
postComment={postComment}
currentUser={currentUser}
assetId={asset.id}
/>
: null}
<TransitionGroup>
{view.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
addTag={addTag}
removeTag={removeTag}
loadMore={loadMore}
ignoreUser={ignoreUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
showSignInDialog={showSignInDialog}
commentIsIgnored={commentIsIgnored}
liveUpdates={liveUpdates}
reactKey={reply.id}
key={reply.id}
comment={reply}
/>;
})}
</TransitionGroup>
<div className="talk-load-more-replies">
<LoadMore
topLevel={false}
replyCount={moreRepliesCount}
moreComments={hasMoreComments}
loadMore={this.loadNewReplies}
loadingState={loadingState}
/>
</div>
</div>
);
@@ -1,6 +1,6 @@
import React, {PropTypes} from 'react';
import {notifyForNewCommentStatus} from 'talk-plugin-commentbox/CommentBox';
import {CommentForm} from 'talk-plugin-commentbox/CommentForm';
import {notifyForNewCommentStatus} from 'coral-plugin-commentbox/CommentBox';
import {CommentForm} from 'coral-plugin-commentbox/CommentForm';
import styles from './Comment.css';
import {CountdownSeconds} from './CountdownSeconds';
import {getEditableUntilDate} from './util';
@@ -6,8 +6,6 @@ import t from 'coral-framework/services/i18n';
import {TabBar, Tab, TabContent, TabPane} from 'coral-ui';
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
import Popup from 'coral-framework/components/Popup';
import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty';
import ConfigureStreamContainer
from 'coral-configure/containers/ConfigureStreamContainer';
import cn from 'classnames';
@@ -28,24 +26,12 @@ export default class Embed extends React.Component {
};
render() {
const {activeTab, commentId, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props;
const {activeTab, commentId} = this.props;
const {user} = this.props.auth;
const hasHighlightedComment = !!commentId;
return (
<div className={cn('talk-embed-stream', {'talk-embed-stream-highlight-comment': hasHighlightedComment})}>
<IfSlotIsNotEmpty slot="login">
<Popup
href='embed/stream/login'
title='Login'
features='menubar=0,resizable=0,width=500,height=550,top=200,left=500'
open={showSignInDialog}
focus={signInDialogFocus}
onFocus={focusSignInDialog}
onBlur={blurSignInDialog}
onClose={hideSignInDialog}
/>
</IfSlotIsNotEmpty>
<TabBar
onTabClick={this.changeTab}
activeTab={activeTab}
@@ -1,32 +0,0 @@
.root {
display: inline-block;
color: white;
background: grey;
height: 22px;
box-sizing: border-box;
line-height: 19px;
padding: 2px 6px 2px 4px;
border-radius: 2px;
font-size: 12px;
text-transform: capitalize;
}
.icon {
font-size: 14px;
vertical-align: text-top;
margin: 0;
margin-right: 4px;
}
.label {
display: inline-block;
}
.premod {
background: #063B9A;
}
.rejected {
background: #d03235;
}
@@ -1,36 +0,0 @@
import React, {PropTypes} from 'react';
import t from 'coral-framework/services/i18n';
import styles from './InactiveCommentLabel.css';
import {Icon} from 'coral-ui';
import cn from 'classnames';
const InactiveCommentLabel = ({status, className, ...rest}) => {
let label;
let icon;
switch (status) {
case 'PREMOD':
label = t('modqueue.premod');
icon = 'query_builder';
break;
case 'REJECTED':
label = t('modqueue.rejected');
icon = 'close';
break;
default:
throw new Error(`Unknown inactive status ${status}`);
}
return (
<span {...rest} className={cn(className, styles.root, styles[status.toLowerCase()])}>
<Icon name={icon} className={styles.icon}/>
<span className={styles.label}>{label}</span>
</span>
);
};
InactiveCommentLabel.propTypes = {
status: PropTypes.string.isRequired,
};
export default InactiveCommentLabel;
@@ -14,7 +14,7 @@
.filterWrapper {
position: absolute;
right: 0;
margin-top: 4px;
margin-top: 6px;
}
.highlightedContainer {
@@ -26,4 +26,4 @@
margin-top: 28px;
padding-bottom: 50px;
min-height: 600px;
}
}
@@ -1,23 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import React, {PropTypes} from 'react';
import {StreamError} from './StreamError';
import Comment from '../components/Comment';
import SuspendedAccount from './SuspendedAccount';
import Slot from 'coral-framework/components/Slot';
import InfoBox from 'talk-plugin-infobox/InfoBox';
import InfoBox from 'coral-plugin-infobox/InfoBox';
import {can} from 'coral-framework/services/perms';
import {ModerationLink} from 'talk-plugin-moderation';
import {ModerationLink} from 'coral-plugin-moderation';
import RestrictedMessageBox
from 'coral-framework/components/RestrictedMessageBox';
import t, {timeago} from 'coral-framework/services/i18n';
import {getSlotComponents} from 'coral-framework/helpers/plugins';
import CommentBox from 'talk-plugin-commentbox/CommentBox';
import QuestionBox from 'talk-plugin-questionbox/QuestionBox';
import {isCommentActive} from 'coral-framework/utils';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import QuestionBox from 'coral-plugin-questionbox/QuestionBox';
import {Button, TabBar, Tab, TabCount, TabContent, TabPane} from 'coral-ui';
import cn from 'classnames';
import {getTopLevelParent, attachCommentToParent} from '../graphql/utils';
import {getTopLevelParent} from '../graphql/utils';
import AllCommentsPane from './AllCommentsPane';
import styles from './Stream.css';
@@ -37,29 +35,6 @@ class Stream extends React.Component {
if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) {
this.setState({keepCommentBox: true});
}
this.fallbackAllTab(next);
}
componentDidMount() {
this.fallbackAllTab();
}
fallbackAllTab(props = this.props) {
if (props.activeStreamTab !== 'all') {
const slotPlugins = this.getSlotComponents('streamTabs', props).map((c) => c.talkPluginName);
if (slotPlugins.indexOf(props.activeStreamTab) === -1) {
props.setActiveStreamTab('all');
}
}
}
getSlotProps({data, root, root: {asset}} = this.props) {
return {data, root, asset};
}
getSlotComponents(slot, props = this.props) {
return getSlotComponents(slot, props.reduxState, this.getSlotProps(props));
}
setActiveReplyBox = (id) => {
@@ -82,7 +57,7 @@ class Stream extends React.Component {
setActiveReplyBox,
appendItemArray,
commentClassNames,
root: {asset, asset: {comment, comments, totalCommentCount}, me},
root: {asset, asset: {comments, totalCommentCount}, comment, me},
postComment,
addNotification,
editComment,
@@ -91,6 +66,7 @@ class Stream extends React.Component {
deleteAction,
showSignInDialog,
updateItem,
addTag,
ignoreUser,
activeStreamTab,
setActiveStreamTab,
@@ -98,24 +74,14 @@ class Stream extends React.Component {
loadMoreComments,
viewAllComments,
auth: {loggedIn, user},
removeTag,
editName
} = this.props;
const {keepCommentBox} = this.state;
const open = asset.closedAt === null;
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
let highlightedComment = comment && getTopLevelParent(comment);
if (highlightedComment) {
// 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.
highlightedComment = attachCommentToParent(highlightedComment, comment);
}
}
const highlightedComment = comment && getTopLevelParent(comment);
const banned = user && user.status === 'BANNED';
const temporarilySuspended =
@@ -132,7 +98,6 @@ class Stream extends React.Component {
};
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
const slotProps = this.getSlotProps();
if (!comment && !comments) {
console.error('Talk: No comments came back from the graph given that query. Please, check the query params.');
@@ -191,10 +156,7 @@ class Stream extends React.Component {
</div>
: <p>{asset.settings.closedMessage}</p>}
<Slot
fill="stream"
{...slotProps}
/>
<Slot fill="stream" />
{loggedIn && (
<ModerationLink
@@ -211,6 +173,8 @@ class Stream extends React.Component {
data={data}
root={root}
commentClassNames={commentClassNames}
addTag={addTag}
removeTag={removeTag}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
@@ -240,17 +204,16 @@ class Stream extends React.Component {
<div
className={cn('talk-stream-filter-wrapper', styles.filterWrapper)}
>
<Slot
fill="streamFilter"
{...slotProps}
/>
<Slot fill="streamFilter" />
</div>
<TabBar activeTab={activeStreamTab} onTabClick={setActiveStreamTab} sub>
{this.getSlotComponents('streamTabs').map((PluginComponent) => (
{getSlotComponents('streamTabs').map((PluginComponent) => (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...slotProps}
active={activeStreamTab === PluginComponent.talkPluginName}
data={data}
root={root}
asset={asset}
/>
</Tab>
))}
@@ -259,10 +222,12 @@ class Stream extends React.Component {
</Tab>
</TabBar>
<TabContent activeTab={activeStreamTab} sub>
{this.getSlotComponents('streamTabPanes').map((PluginComponent) => (
{getSlotComponents('streamTabPanes').map((PluginComponent) => (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...slotProps}
data={data}
root={root}
asset={asset}
/>
</TabPane>
))}
@@ -272,6 +237,8 @@ class Stream extends React.Component {
root={root}
comments={comments}
commentClassNames={commentClassNames}
addTag={addTag}
removeTag={removeTag}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
@@ -304,6 +271,12 @@ Stream.propTypes = {
addNotification: PropTypes.func.isRequired,
postComment: PropTypes.func.isRequired,
// dispatch action to add a tag to a comment
addTag: PropTypes.func,
// dispatch action to remove a tag from a comment
removeTag: PropTypes.func,
// dispatch action to ignore another user
ignoreUser: React.PropTypes.func,
@@ -2,25 +2,21 @@
outline: none;
}
.toggler {
composes: buttonReset from "coral-framework/styles/reset.css";
}
/**
/**
* Up/Down Chevrons for the top right menu
*/
.chevron {
}
.chevron:before {
content: '⌃';
display: inline-block;
display: inline-block;
position: relative;
top: 0.25em;
top: 0.25em;
}
/* Down Arrow */
/* Down Arrow */
.chevron.down:before {
display: inline-block;
display: inline-block;
position: relative;
transform: rotate(180deg);
top: 0;
@@ -27,8 +27,9 @@ export default class Toggleable extends React.Component {
const {isOpen} = this.state;
return (
<ClickOutside onClickOutside={this.close}>
<span className={styles.Toggleable}>
<button className={styles.toggler} onClick={this.toggle}>{isOpen ? upArrow : downArrow}</button>
<span className={styles.Toggleable} tabIndex="0" >
<span className={styles.toggler}
onClick={this.toggle}>{isOpen ? upArrow : downArrow}</span>
{isOpen ? children : null}
</span>
</ClickOutside>
@@ -1,7 +1,6 @@
export const SET_ACTIVE_REPLY_BOX = 'SET_ACTIVE_REPLY_BOX';
export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS';
export const VIEW_COMMENT = 'VIEW_COMMENT';
export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME';
export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME';
export const THREADING_LEVEL = process.env.TALK_THREADING_LEVEL;
@@ -1,9 +1,9 @@
import {gql} from 'react-apollo';
import Comment from '../components/Comment';
import {withFragments} from 'coral-framework/hocs';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
const slots = [
const pluginFragments = getSlotsFragments([
'streamQuestionArea',
'commentInputArea',
'commentInputDetailArea',
@@ -12,20 +12,15 @@ const slots = [
'commentContent',
'commentReactions',
'commentAvatar'
];
]);
export default withFragments({
root: gql`
fragment CoralEmbedStream_Comment_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
}
`,
asset: gql`
fragment CoralEmbedStream_Comment_asset on Asset {
__typename
${getSlotFragmentSpreads(slots, 'asset')}
${pluginFragments.spreads('root')}
}
${pluginFragments.definitions('root')}
`,
comment: gql`
fragment CoralEmbedStream_Comment_comment on Comment {
@@ -33,7 +28,6 @@ export default withFragments({
body
created_at
status
replyCount
tags {
tag {
name
@@ -54,7 +48,8 @@ export default withFragments({
edited
editableUntil
}
${getSlotFragmentSpreads(slots, 'comment')}
${pluginFragments.spreads('comment')}
}
${pluginFragments.definitions('comment')}
`
})(Comment);
@@ -3,7 +3,6 @@ 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';
@@ -20,7 +19,7 @@ import t from 'coral-framework/services/i18n';
import {setActiveTab} from '../actions/embed';
const {logout, checkLogin, focusSignInDialog, blurSignInDialog, hideSignInDialog} = authActions;
const {logout, checkLogin} = authActions;
const {fetchAssetSuccess} = assetActions;
class EmbedContainer extends React.Component {
@@ -92,10 +91,10 @@ class EmbedContainer extends React.Component {
}
componentDidUpdate(prevProps) {
if (!get(prevProps, 'root.asset.comment') && get(this.props, 'root.asset.comment')) {
if (!prevProps.root.comment && this.props.root.comment) {
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
setTimeout(() => pym.scrollParentToChildEl('talk-embed-stream-container'), 0);
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
}
}
@@ -186,9 +185,6 @@ const mapDispatchToProps = (dispatch) =>
setActiveTab,
fetchAssetSuccess,
addNotification,
focusSignInDialog,
blurSignInDialog,
hideSignInDialog,
},
dispatch
);
@@ -4,8 +4,8 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../constants/stream';
import {
withPostComment, withPostFlag, withPostDontAgree,
withDeleteAction, withIgnoreUser, withEditComment
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
withAddTag, withRemoveTag, withIgnoreUser, withEditComment,
} from 'coral-framework/graphql/mutations';
import * as authActions from 'coral-framework/actions/auth';
@@ -15,8 +15,9 @@ import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/strea
import Stream from '../components/Stream';
import Comment from './Comment';
import {withFragments} from 'coral-framework/hocs';
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
import {Spinner} from 'coral-ui';
import {getDefinitionName} from 'coral-framework/utils';
import {
findCommentInEmbedQuery,
insertCommentIntoEmbedQuery,
@@ -24,7 +25,6 @@ import {
insertFetchedCommentsIntoEmbedQuery,
nest,
} from '../graphql/utils';
import omit from 'lodash/omit';
const {showSignInDialog} = authActions;
const {addNotification} = notificationActions;
@@ -159,6 +159,7 @@ const commentFragment = gql`
id
...${getDefinitionName(Comment.fragments.comment)}
${nest(`
replyCount(excludeIgnored: $excludeIgnored)
replies(excludeIgnored: $excludeIgnored) {
nodes {
id
@@ -209,6 +210,7 @@ const LOAD_MORE_QUERY = gql`
id
...${getDefinitionName(Comment.fragments.comment)}
${nest(`
replyCount(excludeIgnored: $excludeIgnored)
replies(limit: 3, excludeIgnored: $excludeIgnored) {
nodes {
id
@@ -229,24 +231,24 @@ const LOAD_MORE_QUERY = gql`
${Comment.fragments.comment}
`;
const slots = [
const pluginFragments = getSlotsFragments([
'streamTabs',
'streamTabPanes',
];
]);
const fragments = {
root: gql`
fragment CoralEmbedStream_Stream_root on RootQuery {
comment(id: $commentId) @include(if: $hasComment) {
...CoralEmbedStream_Stream_comment
${nest(`
parent {
...CoralEmbedStream_Stream_comment
...nest
}
`, THREADING_LEVEL)}
}
asset(id: $assetId, url: $assetUrl) {
comment(id: $commentId) @include(if: $hasComment) {
...CoralEmbedStream_Stream_comment
${nest(`
parent {
...CoralEmbedStream_Stream_comment
...nest
}
`, THREADING_LEVEL)}
}
id
title
url
@@ -265,8 +267,8 @@ const fragments = {
charCount
requireEmailConfirmation
}
commentCount(excludeIgnored: $excludeIgnored) @skip(if: $hasComment)
totalCommentCount(excludeIgnored: $excludeIgnored) @skip(if: $hasComment)
commentCount(excludeIgnored: $excludeIgnored)
totalCommentCount(excludeIgnored: $excludeIgnored)
comments(limit: 10, excludeIgnored: $excludeIgnored) @skip(if: $hasComment) {
nodes {
...CoralEmbedStream_Stream_comment
@@ -275,8 +277,7 @@ const fragments = {
startCursor
endCursor
}
${getSlotFragmentSpreads(slots, 'asset')}
...${getDefinitionName(Comment.fragments.asset)}
${pluginFragments.spreads('asset')}
}
me {
status
@@ -287,10 +288,11 @@ const fragments = {
settings {
organizationName
}
${getSlotFragmentSpreads(slots, 'root')}
${pluginFragments.spreads('root')}
...${getDefinitionName(Comment.fragments.root)}
}
${Comment.fragments.asset}
${pluginFragments.definitions('asset')}
${pluginFragments.definitions('root')}
${Comment.fragments.root}
${commentFragment}
`,
@@ -308,9 +310,7 @@ const mapStateToProps = (state) => ({
previousTab: state.embed.previousTab,
activeStreamTab: state.stream.activeTab,
previousStreamTab: state.stream.previousTab,
commentClassNames: state.stream.commentClassNames,
pluginConfig: state.config.plugin_config,
reduxState: omit(state, 'apollo'),
commentClassNames: state.stream.commentClassNames
});
const mapDispatchToProps = (dispatch) =>
@@ -329,6 +329,8 @@ export default compose(
withPostComment,
withPostFlag,
withPostDontAgree,
withAddTag,
withRemoveTag,
withIgnoreUser,
withDeleteAction,
withEditComment,
@@ -84,7 +84,7 @@ const extension = {
}
user {
id
username
name: username
}
action_summaries {
count
@@ -143,7 +143,7 @@ const extension = {
user: {
__typename: 'User',
id: auth.toJS().user.id,
username: auth.toJS().user.username
name: auth.toJS().user.username
},
created_at: new Date().toISOString(),
body,
@@ -162,7 +162,7 @@ const extension = {
},
__typename: 'TagLink'
})),
status: 'NONE',
status: null,
replyCount: 0,
parent: parent_id
? {id: parent_id}
+17 -38
View File
@@ -1,6 +1,4 @@
import update from 'immutability-helper';
import {insertCommentsSorted} from 'coral-framework/utils';
function determineCommentDepth(comment) {
let depth = 0;
let cur = comment;
@@ -12,8 +10,8 @@ function determineCommentDepth(comment) {
}
function applyToCommentsOrigin(root, callback) {
if (root.asset.comment) {
let comment = root.asset.comment;
if (root.comment) {
let comment = root.comment;
for (let depth = 0; depth <= determineCommentDepth(comment); depth++) {
let changes = {$apply: (node) => node ? callback(node) : node};
for (let i = 0; i < depth; i++) {
@@ -24,10 +22,7 @@ function applyToCommentsOrigin(root, callback) {
return {
...root,
asset: {
...root.asset,
comment,
},
comment,
};
}
return update(root, {
@@ -138,8 +133,8 @@ export function findCommentInEmbedQuery(root, callbackOrId) {
if (typeof callbackOrId === 'string') {
callback = (node) => node.id === callbackOrId;
}
if (root.asset.comment) {
return findComment([getTopLevelParent(root.asset.comment)], callback);
if (root.comment) {
return findComment([getTopLevelParent(root.comment)], callback);
}
if (!root.asset.comments) {
return false;
@@ -147,6 +142,14 @@ export function findCommentInEmbedQuery(root, callbackOrId) {
return findComment(root.asset.comments.nodes, callback);
}
const ascending = (a, b) => {
const dateA = new Date(a.created_at);
const dateB = new Date(b.created_at);
if (dateA < dateB) { return -1; }
if (dateA > dateB) { return 1; }
return 0;
};
function findAndInsertFetchedComments(parent, comments, parent_id) {
const isAsset = parent.__typename === 'Asset';
const connectionField = isAsset ? 'comments' : 'replies';
@@ -159,9 +162,11 @@ function findAndInsertFetchedComments(parent, comments, parent_id) {
if (isAsset) {
return nodes.concat(comments.nodes);
}
return insertCommentsSorted(nodes, comments.nodes.filter(
return nodes
.concat(comments.nodes.filter(
(comment) => !nodes.some((node) => node.id === comment.id)
));
))
.sort(ascending);
}},
},
});
@@ -185,32 +190,6 @@ export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) {
return applyToCommentsOrigin(root, (origin) => findAndInsertFetchedComments(origin, 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) {
if (topLevelComment.id === comment.parent.id) {
return update(topLevelComment, {
replies: {
nodes: {
$apply: (nodes) => insertCommentsSorted(nodes, comment),
},
},
replyCount: {
$set: (count) => count + 1,
}
});
}
return update(topLevelComment, {
replies: {
nodes: {
$apply: (nodes) => nodes.map((node) => attachCommentToParent(node, comment)),
},
},
});
}
/**
* Nest a string in itself repeatly until `level` has been reached.
*
+5 -30
View File
@@ -2,22 +2,18 @@ import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
import {checkLogin, handleAuthToken, logout} from 'coral-framework/actions/auth';
import {checkLogin} from 'coral-framework/actions/auth';
import './graphql';
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
import {getStore, injectReducers, addListener} from 'coral-framework/services/store';
import {getStore, injectReducers} from 'coral-framework/services/store';
import {getClient} from 'coral-framework/services/client';
import {createReduxEmitter} from 'coral-framework/services/events';
import pym from 'coral-framework/services/pym';
import AppRouter from './AppRouter';
import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins';
import reducers from './reducers';
import EventEmitter from 'eventemitter2';
import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider';
const store = getStore();
const client = getClient();
const eventEmitter = new EventEmitter({wildcard: true});
loadPluginsTranslations();
injectPluginsReducers();
@@ -43,35 +39,14 @@ if (!window.opener) {
pym.onMessage('config', (config) => {
init(JSON.parse(config));
});
pym.onMessage('login', (token) => {
if (token) {
store.dispatch(handleAuthToken(token));
}
store.dispatch(checkLogin());
});
pym.onMessage('logout', () => {
store.dispatch(logout());
});
} else {
init();
}
// Pass any events through our parent.
eventEmitter.onAny((eventName, value) => {
pym.sendMessage('event', JSON.stringify({eventName, value}));
});
// Add a redux listener to pass through all actions to our event emitter.
addListener(createReduxEmitter(eventEmitter));
}
render(
<EventEmitterProvider eventEmitter={eventEmitter}>
<ApolloProvider client={client} store={store}>
<AppRouter />
</ApolloProvider>
</EventEmitterProvider>
<ApolloProvider client={client} store={store}>
<AppRouter />
</ApolloProvider>
, document.querySelector('#talk-embed-stream-container')
);
@@ -17,11 +17,11 @@ function getQueryVariable(variable) {
const initialState = {
activeReplyBox: '',
assetId: getQueryVariable('asset_id') || '',
assetUrl: getQueryVariable('asset_url') || '',
commentId: getQueryVariable('comment_id') || '',
assetId: getQueryVariable('asset_id'),
assetUrl: getQueryVariable('asset_url'),
commentId: getQueryVariable('comment_id'),
commentClassNames: [],
activeTab: process.env.TALK_DEFAULT_STREAM_TAB,
activeTab: 'all',
previousTab: '',
};
@@ -48,11 +48,6 @@ export default function stream(state = initialState, action) {
...state,
commentId: '',
};
case actions.VIEW_COMMENT:
return {
...state,
commentId: action.id,
};
case actions.ADD_COMMENT_CLASSNAME :
return {
...state,
+22 -24
View File
@@ -79,7 +79,7 @@ body {
}
/* Info Box Styles */
.talk-plugin-infobox-info {
.coral-plugin-infobox-info {
top: 0;
border: 0;
background: #DEEDFF;
@@ -94,26 +94,26 @@ body {
}
.talk-plugin-infobox-info em{
.coral-plugin-infobox-info em{
font-style: italic;
}
.talk-plugin-infobox-info strong{
.coral-plugin-infobox-info strong{
font-weight: bold;
}
.talk-plugin-infobox-info blockquote{
.coral-plugin-infobox-info blockquote{
border-left: solid 2px #2a2a2a;
padding-left: 10px;
}
.talk-plugin-infobox-info a{
.coral-plugin-infobox-info a{
color: #2a2a2a;
}
/* Question Box Styles */
.talk-plugin-questionbox-info {
.coral-plugin-questionbox-info {
top: 0;
border: 0;
background: #F0F0F0;
@@ -129,7 +129,7 @@ body {
display: flex;
}
.talk-plugin-questionbox-icon.bubble{
.coral-plugin-questionbox-icon.bubble{
position: absolute;
top: 8px;
left: 10px;
@@ -138,7 +138,7 @@ body {
z-index: 0;
}
.talk-plugin-questionbox-icon.person{
.coral-plugin-questionbox-icon.person{
z-index: 2;
top: 12px;
left: 12px;
@@ -147,7 +147,7 @@ body {
color: #262626;
}
.talk-plugin-questionbox-box {
.coral-plugin-questionbox-box {
position: relative;
border: 0;
color: white;
@@ -161,7 +161,7 @@ body {
vertical-align: middle;
}
.talk-plugin-questionbox-content {
.coral-plugin-questionbox-content {
padding: 5px;
display: flex;
align-items: center;
@@ -231,34 +231,32 @@ body {
position: relative;
}
.talk-plugin-commentcontent-text {
.coral-plugin-commentcontent-text {
margin-bottom: 7px;
font-size: 16px;
font-weight: 100;
line-height: 1.3;
}
.talk-plugin-author-name-text {
.coral-plugin-author-name-text {
display: inline-block;
margin: 10px 5px 10px 0;
font-weight: bold;
}
.talk-plugin-author-name-bio-flag {
.coral-plugin-author-name-bio-flag {
float: right;
}
/* Tag Labels */
.talk-plugin-tag-label {
.coral-plugin-tag-label {
background-color: #4C1066;
color: white;
display: inline-block;
margin: 0px 5px;
padding: 5px 5px;
border-radius: 2px;
font-size: 12px;
font-weight: bold;
}
/* Comment Action Styles */
@@ -295,44 +293,44 @@ button.comment__action-button[disabled],
color: #F00;
}
.talk-plugin-pubdate-text {
.coral-plugin-pubdate-text {
display: inline-block;
}
/* Flag Styles */
.talk-plugin-flags-popup-form {
.coral-plugin-flags-popup-form {
margin-bottom: 10px;
}
.talk-plugin-flags-popup-header {
.coral-plugin-flags-popup-header {
font-weight: bolder;
font-size: 1.33rem;
margin-bottom: 10px;
}
.talk-plugin-flags-popup-radio {
.coral-plugin-flags-popup-radio {
margin:5px;
}
.talk-plugin-flags-popup-radio-label {
.coral-plugin-flags-popup-radio-label {
margin:5px;
font-weight: 400;
font-size: .9rem;
}
.talk-plugin-flags-popup-counter {
.coral-plugin-flags-popup-counter {
float: left;
margin-top: 21px;
color: #999;
}
.talk-plugin-flags-popup-button {
.coral-plugin-flags-popup-button {
float: right;
margin-top: 10px;
}
.talk-plugin-flags-reason-text {
.coral-plugin-flags-reason-text {
margin-left: 20px;
margin-top: 5px;
width: 75%;
+13 -97
View File
@@ -1,9 +1,7 @@
import pym from 'pym.js';
import URLSearchParams from 'url-search-params';
import {buildUrl} from 'coral-framework/utils';
import queryString from 'query-string';
import EventEmitter from 'eventemitter2';
import {stringify} from 'querystring';
// TODO: Styles should live in a separate file
const snackbarStyles = {
@@ -41,14 +39,14 @@ function buildStreamIframeUrl(talkBaseUrl, query) {
'embed/stream?'
].join('');
url += queryString.stringify(query);
url += stringify(query);
return url;
}
// Set up postMessage listeners/handlers on the pymParent
// e.g. to resize the iframe, and navigate the host page
function configurePymParent(pymParent, eventEmitter, opts) {
function configurePymParent(pymParent, opts) {
let notificationOffset = 200;
let cachedHeight;
const snackbar = document.createElement('div');
@@ -58,10 +56,6 @@ function configurePymParent(pymParent, eventEmitter, opts) {
pymParent.sendMessage('config', JSON.stringify(config));
}
if (opts.events) {
opts.events(eventEmitter);
}
pymParent.onMessage('coral-auth-changed', function(message) {
if (opts.onAuthChanged) {
opts.onAuthChanged(message ? JSON.parse(message) : null);
@@ -104,37 +98,10 @@ function configurePymParent(pymParent, eventEmitter, opts) {
// remove the permalink comment id from the hash
pymParent.onMessage('coral-view-all-comments', function() {
const search = queryString.stringify({
...queryString.parse(location.search),
commentId: undefined,
});
// remove the commentId url param
const url = buildUrl({...location, search});
window.history.replaceState(
{},
document.title,
url,
);
});
// remove the permalink comment id from the hash
pymParent.onMessage('coral-view-comment', function(id) {
const search = queryString.stringify({
...queryString.parse(location.search),
commentId: id,
});
// remove the commentId url param
const url = buildUrl({...location, search});
window.history.replaceState(
{},
document.title,
url,
location.origin + location.pathname + location.search
);
});
@@ -172,12 +139,6 @@ function configurePymParent(pymParent, eventEmitter, opts) {
window.open(url, '_blank').focus();
});
// Pass events from iframe to the event emitter
pymParent.onMessage('event', (raw) => {
const {eventName, value} = JSON.parse(raw);
eventEmitter.emit(eventName, value);
});
// get dimensions of viewport
const viewport = () => {
let e = window, a = 'inner';
@@ -201,23 +162,6 @@ function configurePymParent(pymParent, eventEmitter, opts) {
* @param {String} [opts.asset_url] - Asset URL
* @param {String} [opts.asset_id] - Asset ID
* @param {String} [opts.auth_token] - (optional) A jwt representing the session
* @return {Object}
*
* Example:
* ```
* const embed = Talk.render(document.getElementById('talkStreamEmbed'), opts);
*
* // trigger a login with optional token.
* embed.login(token);
*
* // trigger a logout.
* embed.logout();
*
* // listen to events (in this case all events).
* embed.on('**', function(value) {
* console.log(this.event, value);
* });
* ```
*/
Talk.render = function(el, opts) {
if (!el) {
@@ -249,58 +193,30 @@ Talk.render = function(el, opts) {
let urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('commentId')) {
query.comment_id = urlParams.get('commentId');
}
query.comment_id = urlParams.get('commentId');
if (opts.asset_id) {
query.asset_id = opts.asset_id;
}
query.asset_id = opts.asset_id;
if (opts.asset_url) {
query.asset_url = opts.asset_url;
}
else {
query.asset_url = opts.asset_url;
if (!query.asset_url) {
try {
query.asset_url = document.querySelector('link[rel="canonical"]').href;
} catch (e) {
console.warn(
'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'
);
if (!window.location.origin) {
window.location.origin = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ''}`;
}
query.asset_url = window.location.origin + window.location.pathname;
}
}
const pymParent = new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), {
title: opts.title,
id: `${el.id}_iframe`,
name: `${el.id}_iframe`
});
const eventEmitter = new EventEmitter({wildcard: true});
configurePymParent(
pymParent,
eventEmitter,
new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), {
title: opts.title,
id: `${el.id}_iframe`,
name: `${el.id}_iframe`
}),
opts
);
return {
on(eventName, callback) {
eventEmitter.on(eventName, callback);
},
login(token) {
pymParent.sendMessage('login', token);
},
logout() {
pymParent.sendMessage('logout');
}
};
};
export default Coral;
+40 -29
View File
@@ -7,32 +7,44 @@ import pym from '../services/pym';
import {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
import {isSlotEmpty} from 'plugin-api/beta/client/services';
export const showSignInDialog = () => ({
type: actions.SHOW_SIGNIN_DIALOG,
});
export const hideSignInDialog = () => (dispatch) => {
if (window.opener && window.opener !== window) {
// TODO: We need to address this when we refactor the
// login popup out of the embed.
// we are in a popup
window.close();
} else {
dispatch(checkLogin());
export const showSignInDialog = () => (dispatch, getState) => {
if (isSlotEmpty('login')) {
return;
}
const signInPopUp = window.open(
'/embed/stream/login',
'Login',
'menubar=0,resizable=0,width=500,height=550,top=200,left=500'
);
// Workaround odd behavior in older WebKit versions, where
// onunload is called twice. (Encountered in IOS 8.3)
let loaded = false;
signInPopUp.onload = () => {
loaded = true;
// Fire some actions inside the popups reducer, to initialize required state.
const required = getState().asset.toJS().settings.requireEmailConfirmation;
const redirectUri = getState().auth.toJS().redirectUri;
signInPopUp.coralStore.dispatch(setRequireEmailVerification(required));
signInPopUp.coralStore.dispatch(setRedirectUri(redirectUri));
};
// Use `onunload` instead of `onbeforeunload` which is not supported in IOS Safari.
signInPopUp.onunload = () => {
if (loaded) {
dispatch(checkLogin());
}
};
dispatch({type: actions.SHOW_SIGNIN_DIALOG});
};
export const hideSignInDialog = () => (dispatch) => {
dispatch({type: actions.HIDE_SIGNIN_DIALOG});
window.close();
};
export const focusSignInDialog = () => ({
type: actions.FOCUS_SIGNIN_DIALOG,
});
export const blurSignInDialog = () => ({
type: actions.BLUR_SIGNIN_DIALOG,
});
export const createUsernameRequest = () => ({
type: actions.CREATE_USERNAME_REQUEST
@@ -276,7 +288,9 @@ export const fetchForgotPassword = (email) => (dispatch, getState) => {
export const logout = () => (dispatch) => {
return coralApi('/auth', {method: 'DELETE'}).then(() => {
Storage.removeItem('token');
if (!bowser.safari && !bowser.ios) {
Storage.removeItem('token');
}
// Reset the websocket.
resetWebsocket();
@@ -304,7 +318,9 @@ export const checkLogin = () => (dispatch) => {
coralApi('/auth')
.then((result) => {
if (!result.user) {
Storage.removeItem('token');
if (!bowser.safari && !bowser.ios) {
Storage.removeItem('token');
}
throw new Error('Not logged in');
}
@@ -321,11 +337,6 @@ export const checkLogin = () => (dispatch) => {
})
.catch((error) => {
console.error(error);
if (error.status && error.status === 401) {
// Unauthorized.
Storage.removeItem('token');
}
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(checkLoginFailure(errorMessage));
});
@@ -1,18 +0,0 @@
import React from 'react';
const PropTypes = require('prop-types');
class EventEmitterProvider extends React.Component {
getChildContext() {
return {eventEmitter: this.props.eventEmitter};
}
render() {
return this.props.children;
}
}
EventEmitterProvider.childContextTypes = {
eventEmitter: PropTypes.object,
};
export default EventEmitterProvider;
@@ -1,25 +0,0 @@
import React from 'react';
import {connect} from 'react-redux';
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
function IfSlotIsEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) {
return (
<Component className={className}>
{isSlotEmpty(slot, reduxState, rest) ? children : null}
</Component>
);
}
IfSlotIsEmpty.propTypes = {
slot: PropTypes.string,
className: PropTypes.string,
};
const mapStateToProps = (state) => ({
reduxState: omit(state, 'apollo'),
});
export default connect(mapStateToProps, null)(IfSlotIsEmpty);
@@ -1,25 +0,0 @@
import React from 'react';
import {connect} from 'react-redux';
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
function IfSlotIsNotEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) {
return (
<Component className={className}>
{!isSlotEmpty(slot, reduxState, rest) ? children : null}
</Component>
);
}
IfSlotIsNotEmpty.propTypes = {
slot: PropTypes.string,
className: PropTypes.string,
};
const mapStateToProps = (state) => ({
reduxState: omit(state, 'apollo'),
});
export default connect(mapStateToProps, null)(IfSlotIsNotEmpty);
-152
View File
@@ -1,152 +0,0 @@
import {Component} from 'react';
import PropTypes from 'prop-types';
export default class Popup extends Component {
ref = null;
detectCloseInterval = null;
constructor(props) {
super(props);
if (props.open) {
this.openWindow(props);
}
}
openWindow(props = this.props) {
this.ref = window.open(
props.href,
props.title,
props.features,
);
this.setCallbacks();
}
setCallbacks() {
this.ref.onload = () => {
clearInterval(this.detectCloseInterval);
this.onLoad();
};
this.ref.onfocus = () => {
this.onFocus();
};
this.ref.onblur = () => {
this.onBlur();
};
// Use `onunload` instead of `onbeforeunload` which is not supported in IOS Safari.
this.ref.onunload = () => {
this.onUnload();
const interval = setInterval(() => {
if (this.ref && this.ref.onload === null) {
this.setCallbacks();
clearInterval(interval);
}
}, 50);
this.detectCloseInterval = setInterval(() => {
if (!this.ref || this.ref.closed) {
clearInterval(this.detectCloseInterval);
this.onClose();
}
}, 50);
};
}
closeWindow() {
if (this.ref) {
if (!this.ref.closed) {
this.ref.close();
}
this.ref = null;
}
}
focusWindow() {
if (this.ref && !this.ref.closed) {
this.ref.focus();
}
}
blurWindow() {
if (this.ref && !this.ref.closed) {
this.ref.blur();
}
}
onLoad = () => {
if (this.props.onLoad) {
this.props.onLoad();
}
}
onUnload = () => {
if (this.props.onUnload) {
this.props.onUnload();
}
}
onClose = () => {
if (this.props.onClose) {
this.props.onClose();
}
}
onFocus = () => {
if (this.props.onFocus) {
this.props.onFocus();
}
}
onBlur = () => {
if (this.props.onBlur) {
this.props.onBlur();
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.open && !this.ref) {
this.openWindow(nextProps);
}
if (this.props.open && !nextProps.open) {
this.closeWindow();
}
if (!this.props.focus && nextProps.focus) {
this.focusWindow();
}
if (this.props.focus && !nextProps.focus) {
this.blurWindow();
}
if (this.props.href !== nextProps.href) {
this.ref.location.href = nextProps.href;
}
}
componentWillUnmount() {
this.closeWindow();
}
render() {
return null;
}
}
Popup.propTypes = {
open: PropTypes.bool,
focus: PropTypes.bool,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
onLoad: PropTypes.func,
onUnload: PropTypes.func,
onClose: PropTypes.func,
href: PropTypes.string.isRequired,
features: PropTypes.string,
};
+4 -8
View File
@@ -3,17 +3,15 @@ import cn from 'classnames';
import styles from './Slot.css';
import {connect} from 'react-redux';
import {getSlotElements} from 'coral-framework/helpers/plugins';
import omit from 'lodash/omit';
function Slot ({fill, inline = false, className, reduxState, defaultComponent: DefaultComponent, ...rest}) {
let children = getSlotElements(fill, reduxState, rest);
const pluginConfig = reduxState.config.pluginConfig || {};
function Slot ({fill, inline = false, className, plugin_config: config, defaultComponent: DefaultComponent, ...rest}) {
let children = getSlotElements(fill, {...rest, config});
if (children.length === 0 && DefaultComponent) {
children = <DefaultComponent {...rest} />;
}
return (
<div className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
<div className={cn({[styles.inline]: inline, [styles.debug]: config.debug}, className)}>
{children}
</div>
);
@@ -23,9 +21,7 @@ Slot.propTypes = {
fill: React.PropTypes.string
};
const mapStateToProps = (state) => ({
reduxState: omit(state, 'apollo'),
});
const mapStateToProps = ({config: {plugin_config = {}}}) => ({plugin_config});
export default connect(mapStateToProps, null)(Slot);
-2
View File
@@ -3,8 +3,6 @@ export const CLEAN_STATE = 'CLEAN_STATE';
export const SHOW_SIGNIN_DIALOG = 'SHOW_SIGNIN_DIALOG';
export const HIDE_SIGNIN_DIALOG = 'HIDE_SIGNIN_DIALOG';
export const FOCUS_SIGNIN_DIALOG = 'FOCUS_SIGNIN_DIALOG';
export const BLUR_SIGNIN_DIALOG = 'BLUR_SIGNIN_DIALOG';
export const CREATE_USERNAME_REQUEST = 'CREATE_USERNAME_REQUEST';
export const CREATE_USERNAME_SUCCESS = 'CREATE_USERNAME_SUCCESS';

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