Compare commits

...
104 Commits
Author SHA1 Message Date
Kim Gardner 8b044954d9 Merge pull request #865 from clintabrown/fix-routes-mount-path
Fix paths when TALK_ROOT_URL_MOUNT_PATH is set to true
2017-08-21 15:43:02 +01:00
Wyatt Johnson 170ca044fe Merge branch 'master' into fix-routes-mount-path 2017-08-21 08:29:37 -06:00
Kim Gardner ccf978f87c Merge pull request #867 from coralproject/trust-defaults-change
Changed trust defaults
2017-08-21 15:29:18 +01:00
Wyatt Johnson 65e56d36a1 Merge branch 'master' into fix-routes-mount-path 2017-08-21 08:15:20 -06:00
Wyatt Johnson 1f813da68b changed trust defaults 2017-08-21 08:12:36 -06:00
Kim Gardner 7c7fe1b3cc Merge pull request #863 from coralproject/optimization
Front-End Optimizations
2017-08-21 14:44:49 +01:00
Kim Gardner 262ec5b0f8 Merge branch 'master' into fix-routes-mount-path 2017-08-21 12:48:49 +01:00
Kim Gardner f7b06dc57c Merge branch 'master' into optimization 2017-08-21 12:21:55 +01:00
Kim Gardner c3884ed0ed Merge pull request #864 from coralproject/redis-changes
Redis Improvements
2017-08-21 12:21:09 +01:00
Kim Gardner f744598dfd Merge branch 'master' into redis-changes 2017-08-21 12:07:23 +01:00
Kim Gardner 64d760bffc Merge branch 'master' into fix-routes-mount-path 2017-08-21 12:02:57 +01:00
Kim Gardner c81a17ac25 Merge branch 'master' into optimization 2017-08-21 11:15:33 +01:00
Kim Gardner 7dfb2bded1 Merge pull request #866 from coralproject/quickfix-configure-state
Fix bug in configure
2017-08-21 11:15:14 +01:00
Chi Vinh Le 077bc5ad33 Fix typo 2017-08-21 17:09:15 +07:00
Chi Vinh Le 3b26e855bc Don't manipulate state directly... 2017-08-21 17:02:54 +07:00
Clint Brown 7a0e04ae56 Fix linting 2017-08-21 09:23:52 +10:00
Clint Brown b738961e1c Fix paths when using TALK_ROOT_URL_MOUNT_PATH 2017-08-20 23:29:03 +10:00
Wyatt Johnson 1b09825602 added more debugging to redis, improved retry 2017-08-18 11:50:51 -06:00
Chi Vinh Le f49591225f Use UserDetailComment container 2017-08-19 00:10:31 +07:00
Chi Vinh Le cf1c93f395 Add comments 2017-08-18 23:37:53 +07:00
Chi Vinh Le 4b9990e154 Merge branch 'master' into optimization
Conflicts:
	client/coral-embed-stream/src/components/Comment.js
	client/coral-embed-stream/src/components/Stream.js
2017-08-18 23:30:28 +07:00
Chi Vinh Le 58a5d5eea8 Refactor Moderation Comment a little bit 2017-08-18 23:20:05 +07:00
Chi Vinh Le 5028f4adf4 Better shouldComponentUpdate detection in withFragments 2017-08-18 23:19:10 +07:00
Chi Vinh Le 44ec469fa5 Disable fragment warnings 2017-08-18 22:47:30 +07:00
Kim Gardner 7c7a8d6b64 Merge pull request #862 from coralproject/flagging-reliability
Flagging Reliability
2017-08-18 16:40:49 +01:00
Chi Vinh Le e77a8ce3c7 More comments 2017-08-18 22:36:13 +07:00
Chi Vinh Le f2a67f04f9 Only use Proxy when available 2017-08-18 22:35:13 +07:00
Chi Vinh Le 0a602b79c3 Enable optimization for more slots 2017-08-18 22:21:51 +07:00
Chi Vinh Le b8452b535b Allow passing custom fragments to withReaction and withTag 2017-08-18 22:19:03 +07:00
Chi Vinh Le 0cbf6e24f7 Handle null values 2017-08-18 21:59:43 +07:00
Chi Vinh Le 833e78f99a Integrate ProfileContainer slot into graphql framework 2017-08-18 21:44:56 +07:00
Belen Curcio 64580696fb Reliability Added 2017-08-18 11:42:23 -03:00
Chi Vinh Le e5156f7fc1 Integrate embed slot in to graphql framework 2017-08-18 21:36:14 +07:00
Chi Vinh Le de90bdbd19 Gracefully handle when data is not available 2017-08-18 21:19:13 +07:00
Chi Vinh Le 00f365ab38 Add missing fragments 2017-08-18 20:40:51 +07:00
Chi Vinh Le 9f62754fd7 Show warnings when slot components uses query data without fragments 2017-08-18 20:33:37 +07:00
Belen Curcio 1dcdccecbc UserDetail UI redone and Flagging Reliability added 2017-08-18 10:19:10 -03:00
Chi Vinh Le dc49d23dc8 Define fragments for PermalinkButton 2017-08-18 19:55:51 +07:00
Chi Vinh Le 8eaac3eaa2 Tiny refactor.. 2017-08-18 19:50:56 +07:00
Kim Gardner 87d14f781f Merge pull request #861 from coralproject/fix-emit-showMoreReplies
Fix emit showMoreReplies event
2017-08-18 12:53:21 +01:00
Chi Vinh Le 5e13af8282 Fix emit showMoreReplies event 2017-08-18 18:42:41 +07:00
Chi Vinh Le 6a5f6e3ce1 Adapt loading detection 2017-08-18 17:57:32 +07:00
Chi Vinh Le fabc8b8c46 Check when one of the leaves are empty 2017-08-18 17:49:57 +07:00
Chi Vinh Le 0808f97fb5 Consider state when optimizing comment 2017-08-18 16:40:42 +07:00
Kim Gardner 11bd1a9771 Merge pull request #860 from coralproject/suspect-hl
Highlighting suspect words working
2017-08-18 09:35:16 +01:00
Chi Vinh Le af4b01c2ae Correctly handly activeReplyBox optimization 2017-08-17 23:23:18 +07:00
Chi Vinh Le 25c11ad690 Keep old query data when not changed in withFragments 2017-08-17 23:22:30 +07:00
Belen Curcio ef815005a8 Úpdated suspected words 2017-08-17 13:12:07 -03:00
Belen Curcio abb8b775b9 working banned and suspended words 2017-08-17 12:44:20 -03:00
Chi Vinh Le d228d49c7d Optimize rendering when activeReplyBox changes 2017-08-17 22:44:13 +07:00
Kim Gardner 6289f8021e Merge pull request #858 from coralproject/bump-version-3-2-0
Version 3.2.0
2017-08-17 15:32:30 +01:00
Chi Vinh Le 7ea6d133c2 Filter queryData in withFragments and optimize rendering 2017-08-17 21:31:09 +07:00
Kim Gardner ae71f5ddc3 Merge branch 'master' into bump-version-3-2-0 2017-08-17 15:26:50 +01:00
Kim Gardner fe673c390d Merge pull request #859 from coralproject/update-docs
Use latest version of Talk in examples
2017-08-17 15:26:42 +01:00
Kim Gardner 2022be4134 Use latest version of Talk in examples 2017-08-17 12:04:09 +01:00
Chi Vinh Le 1ff6c5bdcc Use comment container 2017-08-17 17:16:15 +07:00
Chi Vinh Le df141925fb add hoistStatics to withReaction 2017-08-17 17:15:06 +07:00
Kim Gardner 98e2cb3387 Version 3.2.0 2017-08-17 10:28:12 +01:00
Chi Vinh Le f8031be865 callback props from withMutation now keep their identity 2017-08-16 23:41:14 +07:00
Chi Vinh Le ae81d5cb3c Optimize IfSlotIsX rendering 2017-08-16 22:43:48 +07:00
Chi Vinh Le 4aa6b53022 PropTypes for StreamTabPanel 2017-08-16 22:32:44 +07:00
Chi Vinh Le 9ce6ab108a Refactor StreamTabPanel and use hoistStatics for all hocs 2017-08-16 22:15:31 +07:00
Kim Gardner 418e68aa8a Merge pull request #857 from coralproject/route-fixes
Fixed broken route
2017-08-16 15:01:43 +01:00
Wyatt Johnson 76e1d55601 fixed broken route 2017-08-16 07:53:58 -06:00
Kim Gardner 198b347ff0 Merge pull request #856 from coralproject/base-path
Base Path Support
2017-08-16 10:22:50 +01:00
Wyatt Johnson 34a19c532d Merge branch 'master' into base-path 2017-08-15 15:04:37 -06:00
Wyatt Johnson 4c1ebde3cc Merge pull request #848 from coralproject/update-readme
Add important links to README
2017-08-15 15:02:45 -06:00
Wyatt Johnson 83388b0791 Merge branch 'master' into update-readme 2017-08-15 14:54:29 -06:00
Wyatt Johnson 8afbe9d30d applied path fixes to template 2017-08-15 14:45:45 -06:00
Wyatt Johnson 6b60ceb52e Merge branch 'master' into base-path 2017-08-15 14:24:13 -06:00
Wyatt Johnson e71eb28e42 added base path support for router, spelling fixes 2017-08-15 14:19:58 -06:00
Chi Vinh Le 33aea66ff7 Refactor 2017-08-15 21:56:22 +07:00
Chi Vinh Le c3bcf7dd63 Merge branch 'master' into optimization 2017-08-15 21:48:09 +07:00
Chi Vinh Le 2b2e2f9b5f ClickOutside should only toggle action when open 2017-08-15 21:32:44 +07:00
Chi Vinh Le 4cfdb6574b withQuery props should be immutable 2017-08-15 21:23:39 +07:00
Chi Vinh Le b63fce4ebc Optimize Slot rendering 2017-08-15 18:53:06 +07:00
Kim Gardner b81c2e480e Merge pull request #853 from coralproject/rm-immutable-js
Remove immutable js
2017-08-15 11:54:42 +01:00
Kim Gardner d2525ac8cf Merge branch 'master' into rm-immutable-js 2017-08-15 11:49:15 +01:00
Kim Gardner d38128bad9 Merge pull request #850 from coralproject/update-apollo
Update apollo and fix warnings
2017-08-15 11:49:08 +01:00
Kim Gardner 1220ce464e Merge branch 'master' into update-readme 2017-08-15 11:27:53 +01:00
Kim Gardner 1ca40a3592 Merge branch 'master' into rm-immutable-js 2017-08-15 11:27:45 +01:00
Kim Gardner ad4ea08a6a Merge branch 'master' into update-apollo 2017-08-15 11:21:47 +01:00
Kim Gardner dc3adf0413 Merge pull request #851 from coralproject/fix-ignore-user
Fix Ignored User in permalink
2017-08-15 11:05:23 +01:00
Kim Gardner ac2699856f Merge branch 'master' into fix-ignore-user 2017-08-15 10:50:33 +01:00
Kim Gardner c0f66277b3 Merge pull request #854 from coralproject/fix-error-offtopic
Fix console error in offtopic
2017-08-15 10:50:20 +01:00
Chi Vinh Le db37a0b38e Check existance of featuredComments in query 2017-08-15 16:43:44 +07:00
Kim Gardner cadfb57cd2 Merge branch 'master' into fix-error-offtopic 2017-08-15 10:36:19 +01:00
Kim Gardner 2284692931 Merge branch 'master' into fix-ignore-user 2017-08-15 10:31:26 +01:00
Kim Gardner 2827ca5229 Merge branch 'master' into update-readme 2017-08-15 10:28:59 +01:00
Kim Gardner f159d8a488 Merge pull request #855 from coralproject/fix-create-username-dialog
Fix FakeComment styles
2017-08-15 10:28:46 +01:00
Kim Gardner 15f1733fa1 Merge branch 'master' into fix-create-username-dialog 2017-08-15 10:23:28 +01:00
Kim Gardner 776f29a39d Merge pull request #852 from coralproject/fix-dashboard-links
Fix dashboard links
2017-08-15 10:20:10 +01:00
Chi Vinh Le c52a97338c Fix FakeComment styles 2017-08-14 21:25:48 +07:00
Chi Vinh Le abda77a7e7 Fix console error in offtopic 2017-08-14 19:55:04 +07:00
Chi Vinh Le 06cf33521f Remove immutable-js traces from docs 2017-08-14 19:41:35 +07:00
Chi Vinh Le cdfef8c989 Remove immutable 2017-08-14 16:56:51 +07:00
Chi Vinh Le 2e8863285d Rm immutable-js from admin 2017-08-14 16:49:00 +07:00
Chi Vinh Le 7360a43898 Remove immutable from embed stream 2017-08-11 22:50:12 +07:00
Chi Vinh Le 45cf72256b Fix dashboard links 2017-08-11 21:11:59 +07:00
Chi Vinh Le 17bc396b06 Fix Ignored User in permalink 2017-08-11 20:55:23 +07:00
Chi Vinh Le ada5e6f747 Update apollo and fix warnings 2017-08-11 19:53:08 +07:00
Wyatt Johnson 20b182f455 Merge branch 'master' into update-readme 2017-08-11 09:58:06 +10:00
Kim Gardner ed5810aceb Merge branch 'master' into update-readme 2017-08-10 16:50:46 +01:00
Kim Gardner d008947b41 Add important links to README 2017-08-10 15:51:50 +01:00
120 changed files with 2317 additions and 1244 deletions
+14 -2
View File
@@ -5,9 +5,21 @@ 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.
## Documentation
## Important Links
See our [Talk Documentation & Guides](https://coralproject.github.io/talk/).
- Developer Documentation & Setup Guides: https://coralproject.github.io/talk/
- Pivotal Tracker Backlog & Release Schedule: https://www.pivotaltracker.com/n/projects/1863625
## Learn More about Coral
- Community Forums: https://community.coralproject.net/
- Website: https://coralproject.net
- Blog: https://blog.coralproject.net
- Community Guides for Journalism: https://guides.coralproject.net/
## License
+26 -160
View File
@@ -3,71 +3,42 @@ const bodyParser = require('body-parser');
const morgan = require('morgan');
const path = require('path');
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');
const {createGraphOptions} = require('./graph');
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 {BASE_URL, BASE_PATH, MOUNT_PATH} = require('./url');
const routes = require('./routes');
const debug = require('debug')('talk:app');
const app = express();
// Middleware declarations.
//==============================================================================
// APPLICATION WIDE MIDDLEWARE
//==============================================================================
// Add the logging middleware only if we aren't testing.
if (app.get('env') !== 'test') {
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('dev'));
}
//==============================================================================
// APP MIDDLEWARE
//==============================================================================
// Trust the first proxy in front of us, this will enable us to trust the fact
// that SSL was terminated correctly.
app.set('trust proxy', 1);
// We disable frameward on helmet to allow crossdomain injection of the embed
// Enable a suite of security good practices through helmet. We disable
// frameguard to allow crossdomain injection of the embed.
app.use(helmet({
frameguard: false
frameguard: false,
}));
// Compress the responses if appropriate.
app.use(compression());
// Parse the cookies on the request.
app.use(cookieParser());
// Parse the body json if it's there.
app.use(bodyParser.json());
//==============================================================================
// STATIC FILES
//==============================================================================
// If the application is in production mode, then add gzip rewriting for the
// content.
if (process.env.NODE_ENV === 'production') {
app.get('*.js', (req, res, next) => {
const accept = accepts(req);
if (accept.encoding(['gzip']) === 'gzip') {
// Adjsut the headers on the request by adding a content type header
// because express won't be able to detect the mime-type with the .gz
// extension and we need to decalre support for the gzip encoding.
res.set('Content-Type', 'application/javascript');
res.set('Content-Encoding', 'gzip');
// Rewrite the url so that the gzip version will be served instead.
req.url = `${req.url}.gz`;
}
next();
});
}
app.use('/client', express.static(path.join(__dirname, 'dist')));
app.use('/public', express.static(path.join(__dirname, 'public')));
//==============================================================================
// VIEW CONFIGURATION
//==============================================================================
@@ -75,124 +46,19 @@ 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
//==============================================================================
const passportDebug = require('debug')('talk:passport');
// Install the passport plugins.
plugins.get('server', 'passport').forEach((plugin) => {
passportDebug(`added plugin '${plugin.plugin.name}'`);
// Pass the passport.js instance to the plugin to allow it to inject it's
// functionality.
plugin.passport(passport);
});
// Setup the PassportJS Middleware.
app.use(passport.initialize());
// Attach the authentication middleware, this will be responsible for decoding
// (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
//==============================================================================
// GraphQL endpoint.
app.use('/api/v1/graph/ql', apollo.graphqlExpress(createGraphOptions));
// Only include the graphiql tool if we aren't in production mode.
if (app.get('env') !== 'production') {
// Interactive graphiql interface.
app.use('/api/v1/graph/iql', (req, res) => {
res.render('graphiql', {
endpointURL: '/api/v1/graph/ql'
});
});
// GraphQL documention.
app.get('/admin/docs', (req, res) => {
res.render('admin/docs');
});
}
//==============================================================================
// ROUTES
//==============================================================================
app.use('/', require('./routes'));
// Apply the BASE_PATH, BASE_URL, and MOUNT_PATH on the app.locals, which will
// make them available on the templates and the routers.
app.locals.BASE_URL = BASE_URL;
app.locals.BASE_PATH = BASE_PATH;
app.locals.MOUNT_PATH = MOUNT_PATH;
//==============================================================================
// ERROR HANDLING
//==============================================================================
debug(`mounting routes on the ${MOUNT_PATH} path`);
// Catch 404 and forward to error handler.
app.use((req, res, next) => {
next(errors.ErrNotFound);
});
// General error handler. Respond with the message and error if we have it while
// returning a status code that makes sense.
app.use('/api', (err, req, res, next) => {
if (err !== errors.ErrNotFound) {
if (app.get('env') !== 'test' || enabled('talk:errors')) {
console.error(err);
}
}
if (err instanceof errors.APIError) {
res.status(err.status).json({
message: err.message,
error: err
});
} else {
res.status(500).json({});
}
});
app.use('/', (err, req, res, next) => {
if (err !== errors.ErrNotFound) {
console.error(err);
}
i18n.init(req);
if (err instanceof errors.APIError) {
res.status(err.status);
res.render('error', {
message: err.message,
error: app.get('env') === 'development' ? err : {}
});
} else {
res.render('error', {
message: err.message,
error: app.get('env') === 'development' ? err : {}
});
}
});
// Actually apply the routes.
app.use(MOUNT_PATH, routes);
module.exports = app;
+1 -1
View File
@@ -35,7 +35,7 @@ export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filte
// Update an asset state
// Get comments to fill each of the three lists on the mod queue
export const updateAssetState = (id, closedAt) => (dispatch) => {
dispatch({type: UPDATE_ASSET_STATE_REQUEST});
dispatch({type: UPDATE_ASSET_STATE_REQUEST, id, closedAt});
return coralApi(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}})
.then(() => dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
.catch((error) => {
+3 -3
View File
@@ -93,21 +93,21 @@ const validation = (formData, dispatch, next) => {
};
export const submitSettings = () => (dispatch, getState) => {
const settingsFormData = getState().install.toJS().data.settings;
const settingsFormData = getState().install.data.settings;
validation(settingsFormData, dispatch, function() {
dispatch(nextStep());
});
};
export const submitUser = () => (dispatch, getState) => {
const userFormData = getState().install.toJS().data.user;
const userFormData = getState().install.data.user;
validation(userFormData, dispatch, function() {
dispatch(nextStep());
});
};
export const finishInstall = () => (dispatch, getState) => {
const data = getState().install.toJS().data;
const data = getState().install.data;
dispatch(installRequest());
return coralApi('/setup', {method: 'POST', body: data})
.then(() => {
+1 -1
View File
@@ -42,7 +42,7 @@ export const updateDomainlist = (listName, list) => {
};
export const saveSettingsToServer = () => (dispatch, getState) => {
let settings = getState().settings.toJS();
let settings = getState().settings;
if (settings.charCount) {
settings.charCount = parseInt(settings.charCount);
}
@@ -8,14 +8,11 @@ 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);
const searchWords = [
...suspectWords,
...bannedWords,
...linkText
];
return (
<Highlighter
@@ -1,6 +1,78 @@
.copyButton {
float: right;
top: -10px;
background-color: white;
border: solid 1px;
padding: 2px 6px;
height: auto;
line-height: initial;
min-width: auto;
letter-spacing: normal;
font-size: 0.9em;
margin-left: 10px;
}
.userDetailList {
list-style: none;
padding: 0;
margin: 0;
}
.userDetailItem {
margin: 0 5px;
font-weight: 500;
}
.stats {
display: flex;
list-style: none;
padding: 0;
margin: 0;
text-align: center;
margin: 15px 0 5px;
color: #595959;
}
.stat {
margin-right: 20px;
}
.stat:last-child {
margin-right: 0px;
}
.statItem, .statReportResult {
padding: 3px 5px;
background-color: #D8D8D8;
border-radius: 3px;
font-weight: 500;
display: block;
font-size: 0.9em;
line-height: normal;
letter-spacing: 0.4px;
min-width: 60px;
}
.statResult {
font-size: 1.5em;
padding: 5px 0;
display: inline-block;
}
.statReportResult {
color: white;
margin: 5px 0;
font-weight: 400;
}
.statReportResult.reliable {
background-color: #749C48;
}
.statReportResult.neutral {
background-color: #616161;
}
.statReportResult.unreliable {
background-color: #F44336;
}
.memberSince {
@@ -8,27 +80,9 @@
}
.small {
color: #aaa;
}
.stats {
display: flex;
.stat {
margin: 0 4px 10px 0px;
}
.stat:last-child {
margin-right: 0;
}
p {
margin: 0;
}
.stat p:first-child {
font-weight: bold;
}
color: #888888;
font-size: 0.9em;
letter-spacing: 0.4px;
}
.profileEmail {
+46 -29
View File
@@ -1,12 +1,16 @@
import React, {PropTypes} from 'react';
import Comment from './UserDetailComment';
import React from 'react';
import PropTypes from 'prop-types';
import Comment from '../containers/UserDetailComment';
import styles from './UserDetail.css';
import {Button, Drawer, Spinner} from 'coral-ui';
import {Icon, Button, Drawer, Spinner} from 'coral-ui';
import {Slot} from 'coral-framework/components';
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
import {actionsMap} from '../utils/moderationQueueActionsMap';
import ClickOutside from 'coral-framework/components/ClickOutside';
import LoadMore from '../components/LoadMore';
import cn from 'classnames';
import capitalize from 'lodash/capitalize';
import {getReliability} from 'coral-framework/utils/user';
export default class UserDetail extends React.Component {
@@ -56,6 +60,7 @@ export default class UserDetail extends React.Component {
renderLoaded() {
const {
root,
root: {
user,
totalComments,
@@ -74,13 +79,6 @@ export default class UserDetail extends React.Component {
loadMore,
} = this.props;
const localProfile = user.profiles.find((p) => p.provider === 'local');
let profile;
if (localProfile) {
profile = localProfile.id;
}
let rejectedPercent = (rejectedComments / totalComments) * 100;
if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
@@ -94,32 +92,51 @@ export default class UserDetail extends React.Component {
<h3>{user.username}</h3>
<div>
{profile && <input className={styles.profileEmail} readOnly type="text" ref={(ref) => this.profile = ref} value={profile} />}
<ButtonCopyToClipboard className={styles.copyButton} copyText={profile} />
<ul className={styles.userDetailList}>
<li>
<Icon name="assignment_ind"/>
<span className={styles.userDetailItem}>Member Since:</span>
{new Date(user.created_at).toLocaleString()}
</li>
{user.profiles.map(({id}) =>
<li key={id}>
<Icon name="email"/>
<span className={styles.userDetailItem}>Email:</span>
{id} <ButtonCopyToClipboard className={styles.copyButton} icon="content_copy" copyText={id} />
</li>
)}
</ul>
<ul className={styles.stats}>
<li className={styles.stat}>
<span className={styles.statItem}> Total Comments </span>
<spam className={styles.statResult}> {totalComments} </spam>
</li>
<li className={styles.stat}>
<spam className={styles.statItem}> Reject Rate </spam>
<spam className={styles.statResult}> {`${(rejectedPercent).toFixed(1)}%`} </spam>
</li>
<li className={styles.stat}>
<spam className={styles.statItem}> Reports </spam>
<spam className={cn(styles.statReportResult, styles[getReliability(user.reliable.flagger)])}>
{capitalize(getReliability(user.reliable.flagger))}
</spam>
</li>
</ul>
<p className={styles.small}>
Data represents the last six months of activity
</p>
</div>
<Slot
fill="userProfile"
data={this.props.data}
root={this.props.root}
user={user}
queryData={root, user}
/>
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
<hr/>
<p>
<strong>Account summary</strong>
<br/><small className={styles.small}>Data represents the last six months of activity</small>
</p>
<div className={styles.stats}>
<div className={styles.stat}>
<p>Total Comments</p>
<p>{totalComments}</p>
</div>
<div className={styles.stat}>
<p>Reject Rate</p>
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
</div>
</div>
{
selectedCommentIds.length === 0
? (
+2 -4
View File
@@ -74,10 +74,8 @@ class LayoutContainer extends Component {
}
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
TALK_RECAPTCHA_PUBLIC: state.config
.get('data')
.get('TALK_RECAPTCHA_PUBLIC', null)
auth: state.auth,
TALK_RECAPTCHA_PUBLIC: state.config.data.TALK_RECAPTCHA_PUBLIC,
});
const mapDispatchToProps = (dispatch) => ({
@@ -108,7 +108,7 @@ class UserDetailContainer extends React.Component {
return null;
}
const loading = [1, 2, 4].indexOf(this.props.data.networkStatus) >= 0;
const loading = this.props.data.loading;
return <UserDetail
bulkReject={this.bulkReject}
@@ -142,6 +142,9 @@ export const withUserDetailQuery = withQuery(gql`
id
provider
}
reliable {
flagger
}
${getSlotFragmentSpreads(slots, 'user')}
}
totalComments: commentCount(query: {author_id: $author_id})
@@ -169,8 +172,8 @@ const mapStateToProps = (state) => ({
selectedCommentIds: state.userDetail.selectedCommentIds,
statuses: state.userDetail.statuses,
activeTab: state.userDetail.activeTab,
bannedWords: state.settings.toJS().wordlist.banned,
suspectWords: state.settings.toJS().wordlist.suspect,
bannedWords: state.settings.wordlist.banned,
suspectWords: state.settings.wordlist.suspect,
});
const mapDispatchToProps = (dispatch) => ({
+28 -23
View File
@@ -1,35 +1,40 @@
import {Map, List, fromJS} from 'immutable';
import * as actions from '../constants/assets';
import update from 'immutability-helper';
const initialState = Map({
byId: Map(),
ids: List(),
assets: List()
});
const initialState = {
byId: {},
ids: [],
assets: []
};
export default function assets (state = initialState, action) {
switch (action.type) {
case actions.FETCH_ASSETS_SUCCESS:
return replaceAssets(action, state);
case actions.FETCH_ASSETS_SUCCESS: {
const assets = action.assets.reduce((prev, curr) => {
prev[curr.id] = curr;
return prev;
}, {});
return update(state, {
byId: {$set: assets},
count: {$set: action.count},
ids: {$set: Object.keys(assets)},
});
}
case actions.UPDATE_ASSET_STATE_REQUEST:
return state
.setIn(['byId', action.id, 'closedAt'], action.closedAt);
return update(state, {
byId: {
[action.id]: {
closedAt: {$set: action.closedAt},
},
},
});
case actions.UPDATE_ASSETS:
return state
.set('assets', List(action.assets));
return update(state, {
assets: {$set: action.assets},
});
default:
return state;
}
}
const replaceAssets = (action, state) => {
const assets = fromJS(action.assets.reduce((prev, curr) => {
prev[curr.id] = curr;
return prev;
}, {}));
return state
.set('byId', assets)
.set('count', action.count)
.set('ids', List(assets.keys()));
};
+40 -20
View File
@@ -1,43 +1,63 @@
import {Map} from 'immutable';
import * as actions from '../constants/auth';
const initialState = Map({
const initialState = {
loggedIn: false,
user: null,
loginError: null,
loginMaxExceeded: false,
passwordRequestSuccess: null
});
};
export default function auth (state = initialState, action) {
switch (action.type) {
case actions.CHECK_LOGIN_REQUEST:
return state
.set('loadingUser', true);
return {
...state,
loadingUser: true,
};
case actions.CHECK_LOGIN_FAILURE:
return state
.set('loggedIn', false)
.set('loadingUser', false)
.set('user', null);
return {
...state,
loggedIn: false,
loadingUser: false,
user: null,
};
case actions.CHECK_LOGIN_SUCCESS:
return state
.set('loggedIn', true)
.set('loadingUser', false)
.set('user', action.user);
return {
...state,
loggedIn: true,
loadingUser: false,
user: action.user,
};
case actions.LOGOUT:
return initialState;
case actions.LOGIN_SUCCESS:
return state.set('loginMaxExceeded', false).set('loginError', null);
return {
...state,
loginMaxExceeded: false,
loginError: null,
};
case actions.LOGIN_FAILURE:
return state.set('loginError', action.message);
return {
...state,
loginError: action.message,
};
case actions.FETCH_FORGOT_PASSWORD_REQUEST:
return state.set('passwordRequestSuccess', null);
return {
...state,
passwordRequestSuccess: null,
};
case actions.FETCH_FORGOT_PASSWORD_SUCCESS:
return state.set('passwordRequestSuccess', 'If you have a registered account, a password reset link was sent to that email.');
return {
...state,
passwordRequestSuccess: 'If you have a registered account, a password reset link was sent to that email.',
};
case actions.LOGIN_MAXIMUM_EXCEEDED:
return state
.set('loginMaxExceeded', true)
.set('loginError', action.message);
return {
...state,
loginMaxExceeded: true,
loginError: action.message,
};
default :
return state;
}
+57 -44
View File
@@ -1,5 +1,3 @@
import {Map} from 'immutable';
import {
FETCH_COMMENTERS_REQUEST,
FETCH_COMMENTERS_FAILURE,
@@ -13,8 +11,8 @@ import {
HIDE_REJECT_USERNAME_DIALOG
} from '../constants/community';
const initialState = Map({
community: Map(),
const initialState = {
community: {},
isFetchingPeople: false,
errorPeople: '',
accounts: [],
@@ -22,72 +20,87 @@ const initialState = Map({
ascPeople: false,
totalPagesPeople: 0,
pagePeople: 0,
user: Map({}),
user: {},
banDialog: false,
rejectUsernameDialog: false
});
};
export default function community (state = initialState, action) {
switch (action.type) {
case FETCH_COMMENTERS_REQUEST :
return state
.set('isFetchingPeople', true);
return {
...state,
isFetchingPeople: true,
};
case FETCH_COMMENTERS_FAILURE :
return state
.set('isFetchingPeople', false)
.set('errorPeople', action.error);
return {
...state,
isFetchingPeople: false,
errorPeople: action.error,
};
case FETCH_COMMENTERS_SUCCESS : {
const {accounts, type, page, count, limit, totalPages, ...rest} = action; // eslint-disable-line
return state
.merge({
isFetchingPeople: false,
errorPeople: '',
pagePeople: page,
countPeople: count,
limitPeople: limit,
totalPagesPeople: totalPages,
...rest
})
.set('accounts', accounts); // Sets to normal array
return {
...state,
isFetchingPeople: false,
errorPeople: '',
pagePeople: page,
countPeople: count,
limitPeople: limit,
totalPagesPeople: totalPages,
...rest,
accounts, // Sets to normal array
};
}
case SET_ROLE : {
const commenters = state.get('accounts');
const commenters = state.accounts;
const idx = commenters.findIndex((el) => el.id === action.id);
commenters[idx].roles[0] = action.role;
return state.set('accounts', commenters.map((id) => id));
return {
...state,
accounts: commenters.map((id) => id),
};
}
case SET_COMMENTER_STATUS: {
const commenters = state.get('accounts');
const commenters = state.accounts;
const idx = commenters.findIndex((el) => el.id === action.id);
commenters[idx].status = action.status;
return state.set('accounts', commenters.map((id) => id));
return {
...state,
accounts: commenters.map((id) => id),
};
}
case SORT_UPDATE :
return state
.set('fieldPeople', action.sort.field)
.set('ascPeople', !state.get('ascPeople'));
return {
...state,
fieldPeople: action.sort.field,
ascPeople: !state.ascPeople,
};
case HIDE_BANUSER_DIALOG:
return state
.set('banDialog', false);
return {
...state,
banDialog: false,
};
case SHOW_BANUSER_DIALOG:
return state
.merge({
user: Map(action.user),
banDialog: true
});
return {
...state,
user: action.user,
banDialog: true,
};
case HIDE_REJECT_USERNAME_DIALOG:
return state
.set('rejectUsernameDialog', false);
return {
...state,
rejectUsernameDialog: false,
};
case SHOW_REJECT_USERNAME_DIALOG:
return state
.merge({
user: Map(action.user),
rejectUsernameDialog: true
});
return {
...state,
user: action.user,
rejectUsernameDialog: true
};
default :
return state;
}
+7 -6
View File
@@ -1,15 +1,16 @@
import {Map} from 'immutable';
import * as actions from '../actions/config';
const initialState = Map({
data: Map({})
});
const initialState = {
data: {}
};
export default function config (state = initialState, action) {
switch (action.type) {
case actions.CONFIG_UPDATED:
return state.set('data', Map(action.data));
return {
...state,
data: action.data,
};
default:
return state;
}
+82 -49
View File
@@ -1,30 +1,29 @@
import {Map, List} from 'immutable';
import * as actions from '../constants/install';
import update from 'immutability-helper';
const initialState = Map({
const initialState = {
isLoading: false,
data: Map({
settings: Map({
data: {
settings: {
organizationName: '',
domains: Map({
whitelist: List()
})
}),
user: Map({
domains: {
whitelist: [],
}
},
user: {
username: '',
email: '',
password: '',
confirmPassword: ''
})
}),
errors: Map({
}
},
errors: {
organizationName: '',
username: '',
email: '',
password: '',
confirmPassword: ''
}),
},
showErrors: false,
hasError: false,
error: null,
@@ -44,57 +43,91 @@ const initialState = Map({
installRequest: null,
installRequestError: null,
alreadyInstalled: false
});
};
export default function install (state = initialState, action) {
switch (action.type) {
case actions.NEXT_STEP:
return state
.set('step', state.get('step') + 1);
return {
...state,
step: state.step + 1,
};
case actions.PREVIOUS_STEP:
return state
.set('step', state.get('step') - 1);
return {
...state,
step: state.step - 1,
};
case actions.GO_TO_STEP:
return state
.set('step', action.step);
return {
...state,
step: action.step,
};
case actions.UPDATE_PERMITTED_DOMAINS_SETTINGS:
return state
.setIn(['data', 'settings', 'domains', 'whitelist'], action.value);
return update(state, {
data: {
settings: {
domains: {
whitelist: {$set: action.value},
},
},
},
});
case actions.UPDATE_FORMDATA_SETTINGS:
return state
.setIn(['data', 'settings', action.name], action.value);
return update(state, {
data: {
settings: {
[action.name]: {$set: action.value},
},
},
});
case actions.UPDATE_FORMDATA_USER:
return state
.setIn(['data', 'user', action.name], action.value);
return update(state, {
data: {
user: {
[action.name]: {$set: action.value},
},
},
});
case actions.HAS_ERROR:
return state
.merge({
hasError: true,
showErrors: true
});
return {
...state,
hasError: true,
showErrors: true,
};
case actions.ADD_ERROR:
return state
.setIn(['errors', action.name], action.error);
return update(state, {
errors: {
[action.name]: {$set: action.error},
},
});
case actions.CLEAR_ERRORS:
return state
.set('errors', Map());
return {
...state,
errors: {},
};
case actions.INSTALL_REQUEST:
return state
.set('isLoading', true);
return {
...state,
isLoading: true,
};
case actions.INSTALL_SUCCESS:
return state
.set('isLoading', false)
.set('installRequest', 'SUCCESS');
return {
...state,
isLoading: false,
installRequest: 'SUCCESS',
};
case actions.INSTALL_FAILURE:
return state
.merge({
isLoading: false,
installRequest: 'FAILURE',
installRequestError: action.error
});
return {
...state,
isLoading: false,
installRequest: 'FAILURE',
installRequestError: action.error
};
case actions.CHECK_INSTALL_SUCCESS:
return state
.set('alreadyInstalled', action.installed);
return {
...state,
alreadyInstalled: action.installed,
};
default :
return state;
}
+31 -14
View File
@@ -1,37 +1,54 @@
import {fromJS} from 'immutable';
import * as actions from '../constants/moderation';
const initialState = fromJS({
const initialState = {
singleView: false,
modalOpen: false,
storySearchVisible: false,
storySearchString: '',
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show',
sortOrder: 'REVERSE_CHRONOLOGICAL',
});
};
export default function moderation (state = initialState, action) {
switch (action.type) {
case actions.MODERATION_CLEAR_STATE:
return initialState;
case actions.TOGGLE_MODAL:
return state
.set('modalOpen', action.open);
return {
...state,
modalOpen: action.open,
};
case actions.SINGLE_VIEW:
return state
.set('singleView', !state.get('singleView'));
return {
...state,
singleView: !state.singleView,
};
case actions.HIDE_SHORTCUTS_NOTE:
return state
.set('shortcutsNoteVisible', 'hide');
return {
...state,
shortcutsNoteVisible: 'hide',
};
case actions.SHOW_STORY_SEARCH:
return state.set('storySearchVisible', true);
return {
...state,
storySearchVisible: true,
};
case actions.HIDE_STORY_SEARCH:
return state.set('storySearchVisible', false);
return {
...state,
storySearchVisible: false,
};
case actions.STORY_SEARCH_CHANGE_VALUE:
return state.set('storySearchString', action.value);
return {
...state,
storySearchString: action.value,
};
case actions.SET_SORT_ORDER:
return state.set('sortOrder', action.order);
default :
return {
...state,
sortOrder: action.order,
};
default:
return state;
}
}
+51 -32
View File
@@ -1,5 +1,5 @@
import {Map, List} from 'immutable';
import * as actions from '../actions/settings';
import update from 'immutability-helper';
// this is initialized here because
// currently you have to reload the dashboard to get new stats
@@ -10,63 +10,82 @@ const DASHBOARD_WINDOW_MINUTES = 5;
let then = new Date();
then.setMinutes(then.getMinutes() - DASHBOARD_WINDOW_MINUTES);
const initialState = Map({
wordlist: Map({
banned: List(),
suspect: List()
}),
const initialState = {
wordlist: {
banned: [],
suspect: []
},
dashboardWindowStart: then.toISOString(),
dashboardWindowEnd: new Date().toISOString(),
domains: Map({
whitelist: List()
}),
domains: {
whitelist: []
},
saveSettingsError: null,
fetchSettingsError: null,
fetchingSettings: false
});
};
export default function settings (state = initialState, action) {
switch (action.type) {
case actions.SETTINGS_LOADING:
return state
.set('fetchingSettings', true)
.set('fetchSettingsError', null);
return {
...state,
fetchingSettings: true,
fetchSettingsError: null,
};
case actions.SETTINGS_RECEIVED:
return state.merge({
return {
...state,
fetchingSettings: false,
fetchSettingsError: null,
...action.settings
});
};
case actions.SETTINGS_FETCH_ERROR:
return state
.set('fetchingSettings', false)
.set('fetchSettingsError', action.error);
return {
...state,
fetchingSettings: false,
fetchSettingsError: action.error,
};
case actions.SETTINGS_UPDATED:
return state.merge({
return {
...state,
fetchingSettings: false,
fetchSettingsError: null,
...action.settings
});
};
case actions.SAVE_SETTINGS_LOADING:
return state
.set('fetchingSettings', true)
.set('saveSettingsError', null);
return {
...state,
fetchingSettings: true,
saveSettingsError: null,
};
case actions.SAVE_SETTINGS_SUCCESS:
return state.merge({
return {
...state,
fetchingSettings: false,
fetchSettingsError: null,
...action.settings
});
};
case actions.SAVE_SETTINGS_FAILED:
return state
.set('fetchingSettings', false)
.set('fetchSettingsError', action.error);
return {
...state,
fetchingSettings: false,
fetchSettingsError: action.error,
};
case actions.WORDLIST_UPDATED:
return state
.setIn(['wordlist', action.listName], action.list);
return update(state, {
wordlist: {
[action.listName]: {
$set: action.list
}
}
});
case actions.DOMAINLIST_UPDATED:
return state
.setIn(['domains', action.listName], action.list);
return update(state, {
domains: {
[action.listName]: {$set: action.list},
}
});
default:
return state;
}
@@ -87,8 +87,8 @@ export const withCommunityQuery = withQuery(gql`
});
const mapStateToProps = (state) => ({
community: state.community.toJS(),
currentUser: state.auth.toJS().user,
community: state.community,
currentUser: state.auth.user,
});
const mapDispatchToProps = (dispatch) =>
@@ -23,7 +23,7 @@ class TableContainer extends Component {
}
const mapStateToProps = (state) => ({
commenters: state.community.get('accounts'),
commenters: state.community.accounts,
});
const mapDispatchToProps = (dispatch) =>
@@ -23,8 +23,8 @@ class ConfigureContainer extends Component {
}
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
settings: state.settings.toJS()
auth: state.auth,
settings: state.settings
});
const mapDispatchToProps = (dispatch) =>
@@ -17,11 +17,11 @@ const ActivityWidget = ({assets}) => {
? assets.map((asset) => {
return (
<div className={styles.rowLinkify} key={asset.id}>
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
<Link className={styles.linkToModerate} to={`/admin/moderate/${asset.id}`}>Moderate</Link>
<p className={styles.widgetCount}>{asset.commentCount}</p>
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
<p className={styles.assetTitle}>{asset.title}</p>
</Link>
</a>
<p className={styles.lede}>{asset.author} Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</div>
);
@@ -24,11 +24,11 @@ const FlagWidget = ({assets}) => {
return (
<div className={styles.rowLinkify} key={asset.id}>
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
<Link className={styles.linkToModerate} to={`/admin/moderate/reported/${asset.id}`}>Moderate</Link>
<p className={styles.widgetCount}>{flagSummary ? flagSummary.actionCount : 0}</p>
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
<p className={styles.assetTitle}>{asset.title}</p>
</Link>
</a>
<p className={styles.lede}>{asset.author} Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</div>
);
@@ -19,11 +19,11 @@ const LikeWidget = ({assets}) => {
const likeSummary = asset.action_summaries.find((s) => s.type === 'LikeAssetActionSummary');
return (
<div className={styles.rowLinkify} key={asset.id}>
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
<Link className={styles.linkToModerate} to={`/admin/moderate/${asset.id}`}>Moderate</Link>
<p className={styles.widgetCount}>{likeSummary ? likeSummary.actionCount : 0}</p>
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
<p className={styles.assetTitle}>{asset.title}</p>
</Link>
</a>
<p className={styles.lede}>{asset.author} Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</div>
);
@@ -54,8 +54,8 @@ export const witDashboardQuery = withQuery(gql`
const mapStateToProps = (state) => {
return {
settings: state.settings.toJS(),
moderation: state.moderation.toJS()
settings: state.settings,
moderation: state.moderation
};
};
@@ -35,7 +35,7 @@ InstallContainer.contextTypes = {
};
const mapStateToProps = (state) => ({
install: state.install.toJS()
install: state.install
});
const mapDispatchToProps = (dispatch) =>
@@ -20,6 +20,31 @@ import t, {timeago} from 'coral-framework/services/i18n';
class Comment extends React.Component {
showSuspendUserDialog = () => {
const {comment, showSuspendUserDialog} = this.props;
return showSuspendUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
};
showBanUserDialog = () => {
const {comment, showBanUserDialog} = this.props;
return showBanUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
};
viewUserDetail = () => {
const {viewUserDetail, comment} = this.props;
return viewUserDetail(comment.user.id);
};
render() {
const {
actions = [],
@@ -29,7 +54,12 @@ class Comment extends React.Component {
bannedWords,
selected,
className,
...props
data,
root,
currentUserId,
currentAsset,
acceptComment,
rejectComment,
} = this.props;
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
@@ -38,19 +68,7 @@ class Comment extends React.Component {
let selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
const showSuspenUserDialog = () => props.showSuspendUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
const showBanUserDialog = () => props.showBanUserDialog({
userId: comment.user.id,
username: comment.user.username,
commentId: comment.id,
commentStatus: comment.status,
});
const queryData = {root, comment, asset: comment.asset};
return (
<li
@@ -62,7 +80,7 @@ class Comment extends React.Component {
<div className={styles.author}>
{
(
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
<span className={styles.username} onClick={this.viewUserDetail}>
{comment.user.username}
</span>
)
@@ -75,15 +93,15 @@ class Comment extends React.Component {
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
{props.currentUserId !== comment.user.id &&
{currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={showSuspenUserDialog}>
onClick={this.showSuspendUserDialog}>
Suspend User</ActionsMenuItem>
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={showBanUserDialog}>
onClick={this.showBanUserDialog}>
Ban User
</ActionsMenuItem>
</ActionsMenu>
@@ -91,11 +109,9 @@ class Comment extends React.Component {
<div className={styles.adminCommentInfoBar}>
<CommentType type={commentType} className={styles.commentType}/>
<Slot
data={props.data}
root={props.root}
comment={comment}
asset={comment.asset}
fill="adminCommentInfoBar"
data={data}
queryData={queryData}
/>
</div>
</div>
@@ -103,7 +119,7 @@ class Comment extends React.Component {
<div className={styles.moderateArticle}>
Story: {comment.asset.title}
{!props.currentAsset &&
{!currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
</div>
<CommentAnimatedEdit body={comment.body}>
@@ -124,10 +140,9 @@ class Comment extends React.Component {
</a>
</p>
<Slot
data={props.data}
root={props.root}
fill="adminCommentContent"
comment={comment}
data={data}
queryData={queryData}
/>
<div className={styles.sideActions}>
<IfHasLink text={comment.body}>
@@ -150,30 +165,28 @@ class Comment extends React.Component {
acceptComment={() =>
(comment.status === 'ACCEPTED'
? null
: props.acceptComment({commentId: comment.id}))}
: acceptComment({commentId: comment.id}))}
rejectComment={() =>
(comment.status === 'REJECTED'
? null
: props.rejectComment({commentId: comment.id}))}
: rejectComment({commentId: comment.id}))}
/>
);
})}
</div>
<Slot
data={props.data}
root={props.root}
fill="adminSideActions"
comment={comment}
data={data}
queryData={queryData}
/>
</div>
</div>
</CommentAnimatedEdit>
</div>
<Slot
data={props.data}
root={props.root}
fill="adminCommentDetailArea"
comment={comment}
data={data}
queryData={queryData}
/>
{flagActions && flagActions.length
? <FlagBox
@@ -168,8 +168,7 @@ export default class Moderation extends Component {
<Slot
data={data}
root={root}
assset={asset}
queryData={{root, asset}}
activeTab={activeTab}
handleCommentChange={handleCommentChange}
fill='adminModeration'
@@ -376,9 +376,9 @@ const withQueueCountPolling = withQuery(gql`
});
const mapStateToProps = (state) => ({
moderation: state.moderation.toJS(),
settings: state.settings.toJS(),
auth: state.auth.toJS(),
moderation: state.moderation,
settings: state.settings,
auth: state.auth,
});
const mapDispatchToProps = (dispatch) => ({
@@ -12,7 +12,7 @@ class StoriesContainer extends Component {
}
const mapStateToProps = (state) => ({
assets: state.assets.toJS()
assets: state.assets
});
const mapDispatchToProps = (dispatch) =>
@@ -15,7 +15,7 @@ class ConfigureStreamContainer extends Component {
this.state = {
changed: false,
dirtySettings: props.asset.settings,
dirtySettings: {...props.asset.settings},
closedAt: !props.asset.isClosed ? 'open' : 'closed'
};
@@ -48,26 +48,28 @@ class ConfigureStreamContainer extends Component {
changed: false
});
}, 300);
// this.props.loadAsset(this.props.data.asset);
}
}
handleChange (e) {
const changes = {};
// TODO: Dont directly manipulate state and make state change immutable.
if (e.target && e.target.id === 'qboxenable') {
this.state.dirtySettings.questionBoxEnable = e.target.checked;
changes.questionBoxEnable = e.target.checked;
}
if (e.target && e.target.id === 'qboxcontent') {
this.state.dirtySettings.questionBoxContent = e.target.value;
changes.questionBoxContent = e.target.value;
}
if (e.target && e.target.id === 'plinksenable') {
this.state.dirtySettings.premodLinksEnable = e.target.value;
changes.premodLinksEnable = e.target.value;
}
this.setState({
changed: true
changed: true,
dirtySettings: {
...this.state.dirtySettings,
...changes,
},
});
}
@@ -119,7 +121,7 @@ class ConfigureStreamContainer extends Component {
}
const mapStateToProps = (state) => ({
asset: state.asset.toJS()
asset: state.asset
});
const mapDispatchToProps = (dispatch) => ({
@@ -5,7 +5,7 @@ import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import NewCount from './NewCount';
import {TransitionGroup} from 'react-transition-group';
import {forEachError} from 'coral-framework/utils';
import Comment from '../components/Comment';
import Comment from '../containers/Comment';
const hasComment = (nodes, id) => nodes.some((node) => node.id === id);
@@ -16,14 +16,16 @@ import mapValues from 'lodash/mapValues';
import LoadMore from './LoadMore';
import {getEditableUntilDate} from './util';
import {findCommentWithId} from '../graphql/utils';
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, isCommentActive, getShallowChanges} from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
import CommentContainer from '../containers/Comment';
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length;
@@ -72,6 +74,17 @@ const ActionButton = ({children}) => {
);
};
// Determine whether the comment with id is in the part of the comments tree.
function containsCommentId(props, id) {
if (props.comment.id === id) {
return true;
}
if (props.comment.replies) {
return findCommentWithId(props.comment.replies.nodes, id);
}
return false;
}
export default class Comment extends React.Component {
constructor(props) {
@@ -86,7 +99,6 @@ export default class Comment extends React.Component {
// Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment)
isEditing: false,
replyBoxVisible: false,
animateEnter: false,
loadingState: '',
...resetCursors({}, props),
};
@@ -112,20 +124,21 @@ export default class Comment extends React.Component {
}
}
componentWillEnter(callback) {
callback();
const userId = this.props.currentUser ? this.props.currentUser.id : null;
if (this.props.comment.id.indexOf('pending') >= 0) {
return;
}
if (userId && this.props.comment.user.id === userId) {
shouldComponentUpdate(nextProps, nextState) {
// This comment was just added by currentUser.
if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) {
return;
// Specifically handle `activeReplyBox` if it is the only change.
const changes = [...getShallowChanges(this.props, nextProps), ...getShallowChanges(this.state, nextState)];
if (changes.length === 1 && changes[0] === 'activeReplyBox') {
if (
!containsCommentId(this.props, this.props.activeReplyBox) &&
!containsCommentId(nextProps, nextProps.activeReplyBox)
) {
return false;
}
}
this.setState({animateEnter: true});
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
static propTypes = {
@@ -180,6 +193,9 @@ export default class Comment extends React.Component {
// edit a comment, passed (id, asset_id, { body })
editComment: PropTypes.func,
// emit custom events
emit: PropTypes.func.isRequired,
}
editComment = (...args) => {
@@ -207,7 +223,7 @@ export default class Comment extends React.Component {
}
loadNewReplies = () => {
const {replies, replyCount, id} = this.props.comment;
const {comment: {replies, replyCount, id}, emit} = this.props;
if (replyCount > replies.nodes.length) {
this.setState({loadingState: 'loading'});
this.props.loadMore(id)
@@ -221,10 +237,11 @@ export default class Comment extends React.Component {
this.setState({loadingState: 'error'});
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
});
emit('ui.Comment.showMoreReplies', {id});
return;
}
this.setState(resetCursors);
this.props.emit('ui.Comment.showMoreReplies');
emit('ui.Comment.showMoreReplies', {id});
};
showReplyBox = () => {
@@ -240,6 +257,10 @@ export default class Comment extends React.Component {
return;
}
commentPostedHandler = () => {
this.props.setActiveReplyBox('');
}
// getVisibileReplies returns a list containing comments
// which were authored by current user or comes before the `idCursor`.
getVisibileReplies() {
@@ -315,6 +336,8 @@ export default class Comment extends React.Component {
showSignInDialog,
liveUpdates,
commentIsIgnored,
animateEnter,
emit,
commentClassNames = []
} = this.props;
@@ -367,7 +390,7 @@ export default class Comment extends React.Component {
styles[`rootLevel${depth}`],
{
...conditionalClassNames,
[styles.enter]: this.state.animateEnter,
[styles.enter]: animateEnter,
},
);
@@ -387,10 +410,13 @@ export default class Comment extends React.Component {
// props that are passed down the slots.
const slotProps = {
data,
depth,
};
const queryData = {
root,
asset,
comment,
depth,
};
return (
@@ -404,6 +430,7 @@ export default class Comment extends React.Component {
className={`${styles.commentAvatar} talk-stream-comment-avatar`}
fill="commentAvatar"
{...slotProps}
queryData={queryData}
inline
/>
@@ -426,6 +453,7 @@ export default class Comment extends React.Component {
className={styles.commentInfoBar}
fill="commentInfoBar"
{...slotProps}
queryData={queryData}
/>
{ isActive && (currentUser && (comment.user.id === currentUser.id)) &&
@@ -472,6 +500,7 @@ export default class Comment extends React.Component {
fill="commentContent"
defaultComponent={CommentContent}
{...slotProps}
queryData={queryData}
/>
</div>
}
@@ -483,6 +512,7 @@ export default class Comment extends React.Component {
<Slot
fill="commentReactions"
{...slotProps}
queryData={queryData}
inline
/>
{!disableReply &&
@@ -499,6 +529,7 @@ export default class Comment extends React.Component {
fill="commentActions"
wrapperComponent={ActionButton}
{...slotProps}
queryData={queryData}
inline
/>
<ActionButton>
@@ -524,9 +555,7 @@ export default class Comment extends React.Component {
{activeReplyBox === comment.id
? <ReplyBox
commentPostedHandler={() => {
setActiveReplyBox('');
}}
commentPostedHandler={this.commentPostedHandler}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
setActiveReplyBox={setActiveReplyBox}
@@ -542,7 +571,7 @@ export default class Comment extends React.Component {
{view.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
: <CommentContainer
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
@@ -568,6 +597,7 @@ export default class Comment extends React.Component {
reactKey={reply.id}
key={reply.id}
comment={reply}
emit={emit}
/>;
})}
</TransitionGroup>
@@ -28,7 +28,7 @@ export default class Embed extends React.Component {
};
render() {
const {activeTab, commentId, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props;
const {activeTab, commentId, root, data, auth: {showSignInDialog, signInDialogFocus}, blurSignInDialog, focusSignInDialog, hideSignInDialog} = this.props;
const {user} = this.props.auth;
const hasHighlightedComment = !!commentId;
@@ -64,14 +64,18 @@ export default class Embed extends React.Component {
</Tab>
}
</TabBar>
<Slot fill="embed" />
<Slot
data={data}
queryData={{root}}
fill="embed"
/>
<TabContent
activeTab={activeTab}
id='talk-embed-stream-tab-content'
>
<TabPane tabId={'stream'}>
<Stream data={this.props.data} root={this.props.root} />
<Stream data={data} root={root} />
</TabPane>
<TabPane tabId={'profile'}>
<ProfileContainer />
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import {StreamError} from './StreamError';
import Comment from '../components/Comment';
import Comment from '../containers/Comment';
import SuspendedAccount from './SuspendedAccount';
import Slot from 'coral-framework/components/Slot';
import InfoBox from 'talk-plugin-infobox/InfoBox';
@@ -10,16 +10,16 @@ import {ModerationLink} from 'talk-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 {Button, TabBar, Tab, TabCount, TabContent, TabPane} from 'coral-ui';
import {Button, Tab, TabCount, TabPane} from 'coral-ui';
import cn from 'classnames';
import {getTopLevelParent, attachCommentToParent} from '../graphql/utils';
import AllCommentsPane from './AllCommentsPane';
import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
import StreamTabPanel from '../containers/StreamTabPanel';
import styles from './Stream.css';
@@ -35,46 +35,20 @@ class Stream extends React.Component {
componentWillReceiveProps(next) {
// Keep comment box when user was live suspended, banned, ...
if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) {
if (!this.props.userIsDegraged && next.userIsDegraged) {
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) => {
if (!this.props.auth.user) {
this.props.showSignInDialog();
} else {
this.props.setActiveReplyBox(id);
}
commentIsIgnored = (comment) => {
const me = this.props.root.me;
return (
me &&
me.ignoredUsers &&
me.ignoredUsers.find((u) => u.id === comment.user.id)
);
};
userIsDegraged({auth: {user}} = this.props) {
return !can(user, 'INTERACT_WITH_COMMUNITY');
}
render() {
const {
data,
@@ -83,7 +57,7 @@ class Stream extends React.Component {
setActiveReplyBox,
appendItemArray,
commentClassNames,
root: {asset, asset: {comment, comments, totalCommentCount}, me},
root: {asset, asset: {comment, comments, totalCommentCount}},
postComment,
addNotification,
editComment,
@@ -99,7 +73,8 @@ class Stream extends React.Component {
loadMoreComments,
viewAllComments,
auth: {loggedIn, user},
editName
editName,
emit,
} = this.props;
const {keepCommentBox} = this.state;
const open = !asset.isClosed;
@@ -124,16 +99,9 @@ class Stream extends React.Component {
user.suspension.until &&
new Date(user.suspension.until) > new Date();
const commentIsIgnored = (comment) => {
return (
me &&
me.ignoredUsers &&
me.ignoredUsers.find((u) => u.id === comment.user.id)
);
};
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
const slotProps = this.getSlotProps();
const slotProps = {data};
const slotQueryData = {root, asset};
if (!comment && !comments) {
console.error('Talk: No comments came back from the graph given that query. Please, check the query params.');
@@ -195,6 +163,7 @@ class Stream extends React.Component {
<Slot
fill="stream"
queryData={slotQueryData}
{...slotProps}
/>
@@ -229,11 +198,12 @@ class Stream extends React.Component {
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={highlightedComment.id}
commentIsIgnored={commentIsIgnored}
commentIsIgnored={this.commentIsIgnored}
comment={highlightedComment}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
liveUpdates={true}
/>
</div>
@@ -244,58 +214,54 @@ class Stream extends React.Component {
>
<Slot
fill="streamFilter"
queryData={slotQueryData}
{...slotProps}
/>
</div>
<TabBar activeTab={activeStreamTab} onTabClick={setActiveStreamTab} sub>
{this.getSlotComponents('streamTabs').map((PluginComponent) => (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...slotProps}
active={activeStreamTab === PluginComponent.talkPluginName}
/>
<StreamTabPanel
activeTab={activeStreamTab}
setActiveTab={setActiveStreamTab}
fallbackTab={'all'}
tabSlot={'streamTabs'}
tabPaneSlot={'streamTabPanes'}
slotProps={slotProps}
queryData={slotQueryData}
appendTabs={
<Tab tabId={'all'} key='all'>
All Comments <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
</Tab>
))}
<Tab tabId={'all'}>
All Comments <TabCount active={activeStreamTab === 'all'} sub>{totalCommentCount}</TabCount>
</Tab>
</TabBar>
<TabContent activeTab={activeStreamTab} sub>
{this.getSlotComponents('streamTabPanes').map((PluginComponent) => (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...slotProps}
}
appendTabPanes={
<TabPane tabId={'all'} key='all'>
<AllCommentsPane
data={data}
root={root}
comments={comments}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadMoreComments}
loadNewReplies={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
commentIsIgnored={this.commentIsIgnored}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={emit}
/>
</TabPane>
))}
<TabPane tabId={'all'}>
<AllCommentsPane
data={data}
root={root}
comments={comments}
commentClassNames={commentClassNames}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
disableReply={!open}
postComment={postComment}
asset={asset}
currentUser={user}
postFlag={postFlag}
postDontAgree={postDontAgree}
loadMore={loadMoreComments}
loadNewReplies={loadNewReplies}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
commentIsIgnored={commentIsIgnored}
charCountEnable={asset.settings.charCountEnable}
maxCharCount={asset.settings.charCount}
editComment={editComment}
emit={this.props.emit}
/>
</TabPane>
</TabContent>
}
sub
/>
</div>
}
</div>
@@ -0,0 +1,37 @@
import React from 'react';
import {TabBar, TabContent} from 'coral-ui';
import PropTypes from 'prop-types';
class StreamTabPanel extends React.Component {
render() {
const {activeTab, setActiveTab, tabs, tabPanes, sub} = this.props;
return (
<div>
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
{tabs}
</TabBar>
<TabContent activeTab={activeTab} sub={sub}>
{tabPanes}
</TabContent>
</div>
);
}
}
StreamTabPanel.propTypes = {
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
tabs: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)
]),
tabPanes: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)
]),
className: PropTypes.string,
sub: PropTypes.bool,
};
export default StreamTabPanel;
@@ -19,7 +19,9 @@ export default class Toggleable extends React.Component {
}
close = () => {
this.setState({isOpen: false});
if (this.state.isOpen) {
this.setState({isOpen: false});
}
}
render() {
@@ -1,7 +1,11 @@
import {gql} from 'react-apollo';
import {gql, compose} from 'react-apollo';
import React from 'react';
import Comment from '../components/Comment';
import {withFragments} from 'coral-framework/hocs';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
import {THREADING_LEVEL} from '../constants/stream';
import hoistStatics from 'recompose/hoistStatics';
import {nest} from '../graphql/utils';
const slots = [
'streamQuestionArea',
@@ -14,7 +18,75 @@ const slots = [
'commentAvatar'
];
export default withFragments({
/**
* withAnimateEnter is a HOC that passes a property `animateEnter` to the
* underlying BaseComponent. It must be a direct child of a `TransitionGroup`
* from https://github.com/reactjs/react-transition-group and as such must
* be the uppermost HOC applied to the BaseComponent.
*/
const withAnimateEnter = hoistStatics((BaseComponent) => {
class WithAnimateEnter extends React.Component {
state = {
animateEnter: false,
};
componentWillEnter(callback) {
callback();
const userId = this.props.currentUser ? this.props.currentUser.id : null;
if (this.props.comment.id.indexOf('pending') >= 0) {
return;
}
if (userId && this.props.comment.user.id === userId) {
// This comment was just added by currentUser.
if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) {
return;
}
}
this.setState({animateEnter: true});
}
render() {
return <BaseComponent
{...this.props}
animateEnter={this.state.animateEnter}
/>;
}
}
return WithAnimateEnter;
});
const singleCommentFragment = gql`
fragment CoralEmbedStream_Comment_SingleComment on Comment {
id
body
created_at
status
replyCount
tags {
tag {
name
}
}
user {
id
username
}
action_summaries {
__typename
count
current_user {
id
}
}
editing {
edited
editableUntil
}
}
`;
const withCommentFragments = withFragments({
root: gql`
fragment CoralEmbedStream_Comment_root on RootQuery {
__typename
@@ -29,32 +101,27 @@ export default withFragments({
`,
comment: gql`
fragment CoralEmbedStream_Comment_comment on Comment {
id
body
created_at
status
replyCount
tags {
tag {
name
...CoralEmbedStream_Comment_SingleComment
${nest(`
replies(limit: 3, excludeIgnored: $excludeIgnored) {
nodes {
...CoralEmbedStream_Comment_SingleComment
...nest
}
hasNextPage
startCursor
endCursor
}
}
user {
id
username
}
action_summaries {
__typename
count
current_user {
id
}
}
editing {
edited
editableUntil
}
`, THREADING_LEVEL)}
${getSlotFragmentSpreads(slots, 'comment')}
}
${singleCommentFragment}
`
})(Comment);
});
const enhance = compose(
withAnimateEnter,
withCommentFragments,
);
export default enhance(Comment);
@@ -11,7 +11,7 @@ import {Spinner} from 'coral-ui';
import * as authActions from 'coral-framework/actions/auth';
import * as assetActions from 'coral-framework/actions/asset';
import pym from 'coral-framework/services/pym';
import {getDefinitionName} from 'coral-framework/utils';
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
import {withQuery} from 'coral-framework/hocs';
import Embed from '../components/Embed';
import Stream from './Stream';
@@ -146,12 +146,17 @@ const USERNAME_REJECTED_SUBSCRIPTION = gql`
}
`;
const slots = [
'embed',
];
const EMBED_QUERY = gql`
query CoralEmbedStream_Embed($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) {
me {
id
status
}
${getSlotFragmentSpreads(slots, 'root')}
...${getDefinitionName(Stream.fragments.root)}
}
${Stream.fragments.root}
@@ -170,7 +175,7 @@ export const withEmbedQuery = withQuery(EMBED_QUERY, {
});
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
auth: state.auth,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
assetUrl: state.stream.assetUrl,
@@ -10,13 +10,13 @@ import {
import * as authActions from 'coral-framework/actions/auth';
import * as notificationActions from 'coral-framework/actions/notification';
import {editName} from 'coral-framework/actions/user';
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream';
import Stream from '../components/Stream';
import Comment from './Comment';
import {withFragments, withEmit} from 'coral-framework/hocs';
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
import {Spinner} from 'coral-ui';
import {can} from 'coral-framework/services/perms';
import {
findCommentInEmbedQuery,
insertCommentIntoEmbedQuery,
@@ -24,9 +24,8 @@ import {
insertFetchedCommentsIntoEmbedQuery,
nest,
} from '../graphql/utils';
import omit from 'lodash/omit';
const {showSignInDialog} = authActions;
const {showSignInDialog, editName} = authActions;
const {addNotification} = notificationActions;
class StreamContainer extends React.Component {
@@ -141,8 +140,16 @@ class StreamContainer extends React.Component {
clearInterval(this.countPoll);
}
userIsDegraged({auth: {user}} = this.props) {
return !can(user, 'INTERACT_WITH_COMMUNITY');
}
render() {
if (this.props.refetching) {
if (this.props.refetching
|| !this.props.root.asset
|| !this.props.root.asset.comment
&& !this.props.root.asset.comments
) {
return <Spinner />;
}
return <Stream
@@ -150,6 +157,7 @@ class StreamContainer extends React.Component {
loadMore={this.loadMore}
loadMoreComments={this.loadMoreComments}
loadNewReplies={this.loadNewReplies}
userIsDegraged={this.userIsDegraged()}
/>;
}
}
@@ -157,19 +165,11 @@ class StreamContainer extends React.Component {
const commentFragment = gql`
fragment CoralEmbedStream_Stream_comment on Comment {
id
status
user {
id
}
...${getDefinitionName(Comment.fragments.comment)}
${nest(`
replies(excludeIgnored: $excludeIgnored) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
...nest
}
hasNextPage
startCursor
endCursor
}
`, THREADING_LEVEL)}
}
${Comment.fragments.comment}
`;
@@ -206,27 +206,14 @@ const LOAD_MORE_QUERY = gql`
query CoralEmbedStream_LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) {
comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
${nest(`
replies(limit: 3, excludeIgnored: $excludeIgnored) {
nodes {
id
...${getDefinitionName(Comment.fragments.comment)}
...nest
}
hasNextPage
startCursor
endCursor
}
`, THREADING_LEVEL)}
...CoralEmbedStream_Stream_comment
}
hasNextPage
startCursor
endCursor
}
}
${Comment.fragments.comment}
${commentFragment}
`;
const slots = [
@@ -298,7 +285,7 @@ const fragments = {
};
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
auth: state.auth,
refetching: state.embed.refetching,
commentCountCache: state.stream.commentCountCache,
activeReplyBox: state.stream.activeReplyBox,
@@ -311,7 +298,6 @@ const mapStateToProps = (state) => ({
previousStreamTab: state.stream.previousTab,
commentClassNames: state.stream.commentClassNames,
pluginConfig: state.config.plugin_config,
reduxState: omit(state, 'apollo'),
});
const mapDispatchToProps = (dispatch) =>
@@ -0,0 +1,108 @@
import React from 'react';
import StreamTabPanel from '../components/StreamTabPanel';
import {connect} from 'react-redux';
import omit from 'lodash/omit';
import {getSlotComponents, getSlotComponentProps} from 'coral-framework/helpers/plugins';
import {Tab, TabPane} from 'coral-ui';
import {getShallowChanges} from 'coral-framework/utils';
import isEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
class StreamTabPanelContainer extends React.Component {
componentDidMount() {
this.fallbackAllTab();
}
componentWillReceiveProps(next) {
this.fallbackAllTab(next);
}
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change of slot children.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
const prevUuid = this.getSlotComponents(this.props.tabSlot, this.props).map((cmp) => cmp.talkUuid);
const nextUuid = this.getSlotComponents(next.tabSlot, next).map((cmp) => cmp.talkUuid);
return !isEqual(prevUuid, nextUuid);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
fallbackAllTab(props = this.props) {
if (props.activeTab !== props.fallbackTab) {
const slotPlugins = this.getSlotComponents(props.tabSlot, props).map((c) => c.talkPluginName);
if (slotPlugins.indexOf(props.activeTab) === -1) {
props.setActiveTab(props.fallbackTab);
}
}
}
getSlotComponents(slot, props = this.props) {
return getSlotComponents(slot, props.reduxState, props.slotProps, props.queryData);
}
getPluginTabElements(props = this.props) {
return this.getSlotComponents(props.tabSlot).map((PluginComponent) => (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData)}
active={this.props.activeTab === PluginComponent.talkPluginName}
/>
</Tab>
));
}
getPluginTabPaneElements(props = this.props) {
return this.getSlotComponents(props.tabPaneSlot).map((PluginComponent) => (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...getSlotComponentProps(PluginComponent, props.reduxState, props.slotProps, props.queryData)}
/>
</TabPane>
));
}
render() {
return (
<StreamTabPanel
className={this.props.className}
activeTab={this.props.activeTab}
setActiveTab={this.props.setActiveTab}
tabs={this.getPluginTabElements().concat(this.props.appendTabs)}
tabPanes={this.getPluginTabPaneElements().concat(this.props.appendTabPanes)}
sub={this.props.sub}
/>
);
}
}
StreamTabPanelContainer.propTypes = {
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
appendTabs: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)
]),
appendTabPanes: PropTypes.oneOfType([
PropTypes.element,
PropTypes.arrayOf(PropTypes.element)
]),
fallbackTab: PropTypes.string.isRequired,
tabSlot: PropTypes.string.isRequired,
tabPaneSlot: PropTypes.string.isRequired,
slotProps: PropTypes.object.isRequired,
queryData: PropTypes.object,
className: PropTypes.string,
sub: PropTypes.bool,
};
const mapStateToProps = (state) => ({
reduxState: omit(state, 'apollo'),
});
export default connect(mapStateToProps, null)(StreamTabPanelContainer);
+10 -6
View File
@@ -147,13 +147,11 @@ const extension = {
__typename: 'Comment',
user: {
__typename: 'User',
id: auth.toJS().user.id,
username: auth.toJS().user.username
id: auth.user.id,
username: auth.user.username
},
created_at: new Date().toISOString(),
body,
parent_id,
asset_id,
action_summaries: [],
tags: tags.map((tag) => ({
tag: {
@@ -162,15 +160,21 @@ const extension = {
__typename: 'Tag'
},
assigned_by: {
id: auth.toJS().user.id,
id: auth.user.id,
__typename: 'User'
},
__typename: 'TagLink'
})),
status: 'NONE',
replyCount: 0,
asset: {
__typename: 'Asset',
id: asset_id,
title: '',
url: '',
},
parent: parent_id
? {id: parent_id}
? {__typename: 'Comment', id: parent_id}
: null,
replies: {
__typename: 'CommentConnection',
@@ -117,7 +117,7 @@ export function getTopLevelParent(comment) {
return comment;
}
function findComment(nodes, callback) {
export function findComment(nodes, callback) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (callback(node)) {
@@ -133,6 +133,10 @@ function findComment(nodes, callback) {
return false;
}
export function findCommentWithId(nodes, id) {
return findComment(nodes, (node) => node.id === id);
}
export function findCommentInEmbedQuery(root, callbackOrId) {
let callback = callbackOrId;
if (typeof callbackOrId === 'string') {
+1 -1
View File
@@ -13,7 +13,7 @@ body {
width: 100%;
font-size: 14px;
margin: 0px;
padding: 0px 0px 50px 0px;
padding: 0px 0px 100px 0px;
height: auto !important;
}
+2 -2
View File
@@ -13,7 +13,7 @@ const updateAssetSettingsSuccess = (settings) => ({type: actions.UPDATE_ASSET_SE
const updateAssetSettingsFailure = (error) => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE, error});
export const updateConfiguration = (newConfig) => (dispatch, getState) => {
const assetId = getState().asset.toJS().id;
const assetId = getState().asset.id;
dispatch(updateAssetSettingsRequest());
coralApi(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig})
.then(() => {
@@ -27,7 +27,7 @@ export const updateConfiguration = (newConfig) => (dispatch, getState) => {
};
export const updateOpenStream = (closedBody) => (dispatch, getState) => {
const assetId = getState().asset.toJS().id;
const assetId = getState().asset.id;
dispatch(fetchAssetRequest());
coralApi(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody})
.then(() => {
+24 -3
View File
@@ -4,6 +4,7 @@ import * as actions from '../constants/auth';
import * as Storage from '../helpers/storage';
import coralApi, {base} from '../helpers/request';
import pym from '../services/pym';
import {addNotification} from '../actions/notification';
import {resetWebsocket} from 'coral-framework/services/client';
import t from 'coral-framework/services/i18n';
@@ -220,7 +221,7 @@ const signUpSuccess = (user) => ({type: actions.FETCH_SIGNUP_SUCCESS, user});
const signUpFailure = (error) => ({type: actions.FETCH_SIGNUP_FAILURE, error});
export const fetchSignUp = (formData) => (dispatch, getState) => {
const redirectUri = getState().auth.toJS().redirectUri;
const redirectUri = getState().auth.redirectUri;
dispatch(signUpRequest());
coralApi('/users', {
@@ -257,7 +258,7 @@ const forgotPasswordFailure = (error) => ({
export const fetchForgotPassword = (email) => (dispatch, getState) => {
dispatch(forgotPasswordRequest(email));
const redirectUri = getState().auth.toJS().redirectUri;
const redirectUri = getState().auth.redirectUri;
coralApi('/account/password/reset', {
method: 'POST',
body: {email, loc: redirectUri}
@@ -351,7 +352,7 @@ const verifyEmailFailure = () => ({
});
export const requestConfirmEmail = (email) => (dispatch, getState) => {
const redirectUri = getState().auth.toJS().redirectUri;
const redirectUri = getState().auth.redirectUri;
dispatch(verifyEmailRequest());
return coralApi('/users/resend-verify', {
method: 'POST',
@@ -378,3 +379,23 @@ export const setRedirectUri = (uri) => ({
type: actions.SET_REDIRECT_URI,
uri,
});
//==============================================================================
// Edit Username
//==============================================================================
const editUsernameFailure = (error) => ({type: actions.EDIT_USERNAME_FAILURE, error});
const editUsernameSuccess = () => ({type: actions.EDIT_USERNAME_SUCCESS});
export const editName = (username) => (dispatch) => {
return coralApi('/account/username', {method: 'PUT', body: {username}})
.then(() => {
dispatch(editUsernameSuccess());
dispatch(addNotification('success', t('framework.success_name_update')));
})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(editUsernameFailure(errorMessage));
});
};
-21
View File
@@ -1,21 +0,0 @@
import {addNotification} from '../actions/notification';
import coralApi from '../helpers/request';
import * as actions from '../constants/auth';
import t from 'coral-framework/services/i18n';
const editUsernameFailure = (error) => ({type: actions.EDIT_USERNAME_FAILURE, error});
const editUsernameSuccess = () => ({type: actions.EDIT_USERNAME_SUCCESS});
export const editName = (username) => (dispatch) => {
return coralApi('/account/username', {method: 'PUT', body: {username}})
.then(() => {
dispatch(editUsernameSuccess());
dispatch(addNotification('success', t('framework.success_name_update')));
})
.catch((error) => {
console.error(error);
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
dispatch(editUsernameFailure(errorMessage));
});
};
@@ -3,13 +3,36 @@ import {connect} from 'react-redux';
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
import {getShallowChanges} from 'coral-framework/utils';
function IfSlotIsEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) {
return (
<Component className={className}>
{isSlotEmpty(slot, reduxState, rest) ? children : null}
</Component>
);
class IfSlotIsEmpty extends React.Component {
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
isSlotEmpty(props = this.props) {
const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props;
return isSlotEmpty(slot, reduxState, rest);
}
render() {
const {className, component: Component = 'div', children} = this.props;
return (
<Component className={className}>
{this.isSlotEmpty() ? children : null}
</Component>
);
}
}
IfSlotIsEmpty.propTypes = {
@@ -3,13 +3,36 @@ import {connect} from 'react-redux';
import {isSlotEmpty} from 'coral-framework/helpers/plugins';
import PropTypes from 'prop-types';
import omit from 'lodash/omit';
import {getShallowChanges} from 'coral-framework/utils';
function IfSlotIsNotEmpty({slot, className, reduxState, component: Component = 'div', children, ...rest}) {
return (
<Component className={className}>
{!isSlotEmpty(slot, reduxState, rest) ? children : null}
</Component>
);
class IfSlotIsNotEmpty extends React.Component {
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
return this.isSlotEmpty(this.props) !== this.isSlotEmpty(next);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
isSlotEmpty(props = this.props) {
const {slot, className: _a, reduxState, component: _b = 'div', children: _c, ...rest} = props;
return isSlotEmpty(slot, reduxState, rest);
}
render() {
const {className, component: Component = 'div', children} = this.props;
return (
<Component className={className}>
{this.isSlotEmpty() ? null : children}
</Component>
);
}
}
IfSlotIsNotEmpty.propTypes = {
+45 -12
View File
@@ -2,25 +2,58 @@ import React from 'react';
import cn from 'classnames';
import styles from './Slot.css';
import {connect} from 'react-redux';
import {getSlotElements} from 'coral-framework/helpers/plugins';
import {getSlotElements, getSlotComponentProps} from 'coral-framework/helpers/plugins';
import omit from 'lodash/omit';
import isEqual from 'lodash/isEqual';
import {getShallowChanges} from 'coral-framework/utils';
function Slot ({fill, inline = false, className, reduxState, defaultComponent: DefaultComponent, ...rest}) {
let children = getSlotElements(fill, reduxState, rest);
const pluginConfig = reduxState.config.pluginConfig || {};
if (children.length === 0 && DefaultComponent) {
children = <DefaultComponent {...rest} />;
const emptyConfig = {};
class Slot extends React.Component {
shouldComponentUpdate(next) {
// Prevent Slot from rerendering when only reduxState has changed and
// it does not result in a change of slot children.
const changes = getShallowChanges(this.props, next);
if (changes.length === 1 && changes[0] === 'reduxState') {
const prevChildrenUuid = this.getChildren(this.props).map((child) => child.type.talkUuid);
const nextChildrenUuid = this.getChildren(next).map((child) => child.type.talkUuid);
return !isEqual(prevChildrenUuid, nextChildrenUuid);
}
// Prevent Slot from rerendering when no props has shallowly changed.
return changes.length !== 0;
}
return (
<div className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
{children}
</div>
);
getSlotProps({fill: _a, inline: _b, className: _c, reduxState: _d, defaultComponent_: _e, queryData: _f, ...rest} = this.props) {
return rest;
}
getChildren(props = this.props) {
return getSlotElements(props.fill, props.reduxState, this.getSlotProps(props), props.queryData);
}
render() {
const {inline = false, className, reduxState, defaultComponent: DefaultComponent, queryData} = this.props;
let children = this.getChildren();
const pluginConfig = reduxState.config.pluginConfig || emptyConfig;
if (children.length === 0 && DefaultComponent) {
children = <DefaultComponent {...getSlotComponentProps(DefaultComponent, reduxState, this.getSlotProps(this.props), queryData)} />;
}
return (
<div className={cn({[styles.inline]: inline, [styles.debug]: pluginConfig.debug}, className)}>
{children}
</div>
);
}
}
Slot.propTypes = {
fill: React.PropTypes.string
fill: React.PropTypes.string.isRequired,
// props coming from graphql must be passed through this property.
queryData: React.PropTypes.object,
};
const mapStateToProps = (state) => ({
@@ -1,3 +0,0 @@
export const MULTIPLE_ASSETS_REQUEST = 'MULTIPLE_ASSETS_REQUEST';
export const MULTIPLE_ASSETS_SUCCESS = 'MULTIPLE_ASSETS_SUCCESS';
export const MULTIPLE_ASSSETS_FAILURE = 'MULTIPLE_ASSSETS_FAILURE';
-10
View File
@@ -1,10 +0,0 @@
export const EDIT_NAME_REQUEST = 'EDIT_NAME_REQUEST';
export const EDIT_NAME_SUCCESS = 'EDIT_NAME_SUCCESS';
export const EDIT_NAME_FAILURE = 'EDIT_NAME_FAILURE';
export const COMMENTS_BY_USER_REQUEST = 'COMMENTS_BY_USER_REQUEST';
export const COMMENTS_BY_USER_SUCCESS = 'COMMENTS_BY_USER_SUCCESS';
export const COMMENTS_BY_USER_FAILURE = 'COMMENTS_BY_USER_FAILURE';
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
export const UPDATE_USERNAME = 'UPDATE_USERNAME';
export const IGNORE_USER_SUCCESS = 'IGNORE_USER_SUCCESS';
export const STOP_IGNORING_USER_SUCCESS = 'STOP_IGNORING_USER_SUCCESS';
+73 -9
View File
@@ -5,13 +5,19 @@ import merge from 'lodash/merge';
import flattenDeep from 'lodash/flattenDeep';
import isEmpty from 'lodash/isEmpty';
import flatten from 'lodash/flatten';
import mapValues from 'lodash/mapValues';
import {loadTranslations} from 'coral-framework/services/i18n';
import {injectReducers} from 'coral-framework/services/store';
import {getDisplayName} from 'coral-framework/helpers/hoc';
import camelize from './camelize';
import plugins from 'pluginsConfig';
import uuid from 'uuid/v4';
export function getSlotComponents(slot, reduxState, props = {}) {
const pluginConfig = reduxState.config.plugin_config || {};
// This is returned for pluginConfig when it is empty.
const emptyConfig = {};
export function getSlotComponents(slot, reduxState, props = {}, queryData = {}) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return flatten(plugins
// Filter out components that have slots and have been disabled in `plugin_config`
@@ -24,7 +30,7 @@ export function getSlotComponents(slot, reduxState, props = {}) {
if(!component.isExcluded) {
return true;
}
let resolvedProps = {...props, config: pluginConfig};
let resolvedProps = getSlotComponentProps(component, reduxState, props, queryData);
if (component.mapStateToProps) {
resolvedProps = {...resolvedProps, ...component.mapStateToProps(reduxState)};
}
@@ -32,17 +38,70 @@ export function getSlotComponents(slot, reduxState, props = {}) {
});
}
export function isSlotEmpty(slot, reduxState, props) {
return getSlotComponents(slot, reduxState, props).length === 0;
export function isSlotEmpty(slot, reduxState, props = {}, queryData = {}) {
return getSlotComponents(slot, reduxState, props, queryData).length === 0;
}
// Memoize the warnings so we only show them once.
const memoizedWarnings = [];
// withWarnings decorates the props of queryData with a proxy that
// prints a warning when accessing deeper props.
function withWarnings(component, queryData) {
if (process.env.NODE_ENV !== 'production' && window.Proxy) {
// Show warnings when accessing queryData only when not in production.
return mapValues(queryData, (value, key) => {
// Keep null values..
if (!queryData[key]) {
return queryData[key];
}
return new Proxy(queryData[key], {
get(target, name) {
// Only care about the components defined in the plugins.
if (component.talkPluginName) {
const warning = `'${getDisplayName(component)}' of '${component.talkPluginName}' accessed '${key}.${name}' but did not define fragments using the withFragment HOC`;
if (memoizedWarnings.indexOf(warning) === -1) {
console.warn(warning);
memoizedWarnings.push(warning);
}
}
return queryData[key][name];
}
});
});
}
return queryData;
}
/**
* getSlotComponentProps calculate the props we would pass to the slot component.
* query datas are only passed to the component if it is defined in `component.fragments`.
*/
export function getSlotComponentProps(component, reduxState, props, queryData) {
const pluginConfig = reduxState.config.plugin_config || emptyConfig;
return {
...props,
config: pluginConfig,
...(
component.fragments
? pick(queryData, Object.keys(component.fragments))
: withWarnings(component, queryData)
)
};
}
/**
* Returns React Elements for given slot.
*/
export function getSlotElements(slot, reduxState, props = {}) {
const pluginConfig = reduxState.config.plugin_config || {};
return getSlotComponents(slot, reduxState, props)
.map((component, i) => React.createElement(component, {key: i, ...props, config: pluginConfig}));
export function getSlotElements(slot, reduxState, props = {}, queryData = {}) {
return getSlotComponents(slot, reduxState, props, queryData)
.map((component, i) => {
return React.createElement(component, {key: i, ...getSlotComponentProps(component, reduxState, props, queryData)});
});
}
export function getSlotFragments(slot, part) {
@@ -100,7 +159,12 @@ function addMetaDataToSlotComponents() {
const slots = plugin.module.slots;
slots && Object.keys(slots).forEach((slot) => {
slots[slot].forEach((component) => {
// Attach plugin name to the component
component.talkPluginName = plugin.name;
// Attach uuid to the component
component.talkUuid = uuid();
});
});
});
@@ -1,8 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import Clipboard from 'clipboard';
import hoistStatics from 'recompose/hoistStatics';
export default (WrappedComponent) => {
export default hoistStatics((WrappedComponent) => {
class WithCopyToClipboard extends React.Component {
componentDidMount() {
const clipboard = new Clipboard(ReactDOM.findDOMNode(this));
@@ -26,4 +27,4 @@ export default (WrappedComponent) => {
}
return WithCopyToClipboard;
};
});
+4 -3
View File
@@ -1,11 +1,12 @@
import React from 'react';
const PropTypes = require('prop-types');
import hoistStatics from 'recompose/hoistStatics';
import PropTypes from 'prop-types';
/**
* WithEmit provides a property `emit: (eventName, value)`
* to the wrapped component.
*/
export default (WrappedComponent) => {
export default hoistStatics((WrappedComponent) => {
class WithEmit extends React.Component {
static contextTypes = {
eventEmitter: PropTypes.object,
@@ -24,4 +25,4 @@ export default (WrappedComponent) => {
}
return WithEmit;
};
});
+109 -5
View File
@@ -1,5 +1,109 @@
// TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38.
export default (fragments) => (BaseComponent) => {
BaseComponent.fragments = fragments;
return BaseComponent;
};
import React from 'react';
import graphql from 'graphql-anywhere';
import {resolveFragments} from 'coral-framework/services/graphqlRegistry';
import mapValues from 'lodash/mapValues';
import hoistStatics from 'recompose/hoistStatics';
import {getShallowChanges} from 'coral-framework/utils';
import union from 'lodash/union';
// TODO: Should not depend on `props.data`
// Currently necessary because of this https://github.com/apollographql/graphql-anywhere/issues/38
function filter(doc, data, variables) {
const resolver = (
fieldName,
root,
args,
context,
info,
) => {
return root[info.resultKey];
};
return graphql(resolver, doc, data, null, variables);
}
// filterProps returns only the property as defined in the fragments.
// TODO: Should not depend on `props.data`
function filterProps(props, fragments) {
const filtered = {};
Object.keys(fragments).forEach((key) => {
if (!(key in props)) {
return;
}
filtered[key] = props.data
? filter(fragments[key], props[key], props.data.variables)
: props[key];
});
return filtered;
}
// hasEqualLeaves compares two different apollo query result for equality.
function hasEqualLeaves(a, b, path = '') {
for (const key of union(Object.keys(a), Object.keys(b))) {
if (!(key in a) || !(key in b)) {
return false;
}
if (typeof a[key] === 'object' && a[key] && b[key]) {
if (Array.isArray(a[key])) {
if (a[key].length !== b[key].length) {
return false;
}
}
if (!hasEqualLeaves(a[key], b[key], `${path}.${key}`)) {
return false;
}
continue;
}
if (a[key] !== b[key]) {
return false;
}
}
return true;
}
export default (fragments) => hoistStatics((BaseComponent) => {
class WithFragments extends React.Component {
fragments = mapValues(fragments, (val) => resolveFragments(val));
fragmentKeys = Object.keys(fragments).sort();
// Cache variables between lifecycles to speed up render.
filteredProps = filterProps(this.props, this.fragments)
queryDataHasChanged = false;
shallowChanges = null;
componentWillReceiveProps(next) {
this.shallowChanges = getShallowChanges(this.props, next);
if (this.fragmentKeys.some((key) => this.shallowChanges.indexOf(key) >= 0)) {
const nextFilteredProps = filterProps(next, this.fragments);
this.queryDataHasChanged = !hasEqualLeaves(this.filteredProps, nextFilteredProps);
if (this.queryDataHasChanged) {
// Only changed props when query data has changed.
this.filteredProps = filterProps(next, this.fragments);
}
}
}
shouldComponentUpdate(next) {
const onlyQueryDataChanges = this.shallowChanges.every((key) => this.fragmentKeys.indexOf(key) >= 0);
if (onlyQueryDataChanges) {
return this.queryDataHasChanged;
}
return this.shallowChanges.length !== 0;
}
render() {
const queryProps = this.filteredProps;
return <BaseComponent
{...this.props}
{...queryProps}
/>;
}
}
WithFragments.fragments = fragments;
return WithFragments;
});
+37 -7
View File
@@ -8,6 +8,8 @@ import {getMutationOptions, resolveFragments} from 'coral-framework/services/gra
import {getDefinitionName, getResponseErrors} from '../utils';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
import hoistStatics from 'recompose/hoistStatics';
import union from 'lodash/union';
class ResponseErrors extends Error {
constructor(errors) {
@@ -30,7 +32,7 @@ class ResponseError {
* Exports a HOC with the same signature as `graphql`, that will
* apply mutation options registered in the graphRegistry.
*/
export default (document, config = {}) => (WrappedComponent) => {
export default (document, config = {}) => hoistStatics((WrappedComponent) => {
config = {
...config,
options: config.options || {},
@@ -46,7 +48,13 @@ export default (document, config = {}) => (WrappedComponent) => {
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
wrappedProps = (data) => {
// Props as we would pass to the BaseComponent without optimizations.
dynamicProps = {};
// Props that are optimized by keeping the identity of function callbacks.
staticProps = {};
propsWrapper = (data) => {
const name = getDefinitionName(document);
const callbacks = getMutationOptions(name);
const mutate = (base) => {
@@ -92,13 +100,13 @@ export default (document, config = {}) => (WrappedComponent) => {
// Do not run updates when we have mutation errors.
return prev;
}
return map[key](prev, result);
return map[key](prev, result) || prev;
};
} else {
const existing = res[key];
res[key] = (prev, result) => {
const next = existing(prev, result);
return map[key](next, result);
return map[key](next, result) || next;
};
}
});
@@ -132,12 +140,34 @@ export default (document, config = {}) => (WrappedComponent) => {
throw error;
});
};
return config.props({...data, mutate});
// Save current props to `dynamicProps`
this.dynamicProps = config.props({...data, mutate});
// Sync props to `staticProps`.
// `staticProps` ultimately contains the same props as `dynamicProps` but all callbacks
// keep their identity.
union(Object.keys(this.dynamicProps), Object.keys(this.staticProps)).forEach((key) => {
if (!(key in this.dynamicProps)) {
delete this.staticProps[key];
return;
}
if (typeof this.dynamicProps[key] !== 'function') {
this.staticProps[key] = this.dynamicProps[key];
return;
}
if (!(key in this.staticProps)) {
this.staticProps[key] = (...args) => this.dynamicProps[key](...args);
return;
}
});
return this.staticProps;
};
getWrapped = () => {
if (!this.memoized) {
this.memoized = graphql(resolveFragments(document), {...config, props: this.wrappedProps})(WrappedComponent);
this.memoized = graphql(resolveFragments(document), {...config, props: this.propsWrapper})(WrappedComponent);
}
return this.memoized;
};
@@ -147,4 +177,4 @@ export default (document, config = {}) => (WrappedComponent) => {
return <Wrapped {...this.props} />;
}
};
};
});
+89 -51
View File
@@ -3,6 +3,7 @@ import {graphql} from 'react-apollo';
import {getQueryOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry';
import {getDefinitionName, separateDataAndRoot, getResponseErrors} from '../utils';
import PropTypes from 'prop-types';
import hoistStatics from 'recompose/hoistStatics';
const withSkipOnErrors = (reducer) => (prev, action, ...rest) => {
if (action.type === 'APOLLO_MUTATION_RESULT' && getResponseErrors(action.result)) {
@@ -35,7 +36,7 @@ function networkStatusToString(networkStatus) {
* Exports a HOC with the same signature as `graphql`, that will
* apply query options registered in the graphRegistry.
*/
export default (document, config = {}) => (WrappedComponent) => {
export default (document, config = {}) => hoistStatics((WrappedComponent) => {
const name = getDefinitionName(document);
return class WithQuery extends React.Component {
@@ -46,6 +47,7 @@ export default (document, config = {}) => (WrappedComponent) => {
// Lazily resolve fragments from graphRegistry to support circular dependencies.
memoized = null;
lastNetworkStatus = null;
data = null;
emitWhenNeeded(data) {
const {variables, networkStatus} = data;
@@ -60,59 +62,93 @@ export default (document, config = {}) => (WrappedComponent) => {
this.context.eventEmitter.emit(`query.${name}.${status}`, {variables, data: root});
}
nextData(data) {
this.emitWhenNeeded(data);
// If data was previously set, we update it in a immutable way.
if (this.data) {
if (this.data.networkStatus !== data.networkStatus ||
this.data.loading !== data.loading ||
this.data.error !== data.error ||
this.data.variables !== data.variables) {
this.data = {
...this.data,
error: data.error,
networkStatus: data.networkStatus,
loading: data.loading,
variables: data.variables,
};
}
}
else {
// Set data for the first time.
this.data = {
error: data.error,
variables: data.variables,
networkStatus: data.networkStatus,
loading: data.loading,
startPolling: data.startPolling,
stopPolling: data.stopPolling,
refetch: data.refetch,
updateQuery: data.updateQuery,
subscribeToMore: (stmArgs) => {
// Resolve document fragments before passing it to `apollo-client`.
return data.subscribeToMore({
...stmArgs,
document: resolveFragments(stmArgs.document),
onError: (err) => {
if (stmArgs.onErr) {
return stmArgs.onErr(err);
}
throw err;
},
});
},
fetchMore: (lmArgs) => {
const fetchName = getDefinitionName(lmArgs.query);
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.begin`,
{variables: lmArgs.variables});
// Resolve document fragments before passing it to `apollo-client`.
return data.fetchMore({
...lmArgs,
query: resolveFragments(lmArgs.query),
})
.then((res) => {
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.success`,
{variables: lmArgs.variables, data: res.data});
return Promise.resolve(res);
})
.catch((err) => {
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.error`,
{variables: lmArgs.variables, error: err});
throw err;
});
},
};
}
return this.data;
}
wrappedConfig = {
...config,
options: config.options || {},
props: (args) => {
this.emitWhenNeeded(args.data);
const nextData = this.nextData(args.data);
const {root} = separateDataAndRoot(args.data);
if (config.props) {
const wrappedArgs = {
...args,
data: {
...args.data,
subscribeToMore: (stmArgs) => {
// Custom props, in this case we just pass the wrapped args to it.
return config.props({...args, data: {...args.data, ...nextData}});
}
// Resolve document fragments before passing it to `apollo-client`.
return args.data.subscribeToMore({
...stmArgs,
document: resolveFragments(stmArgs.document),
onError: (err) => {
if (stmArgs.onErr) {
return stmArgs.onErr(err);
}
throw err;
},
});
},
fetchMore: (lmArgs) => {
const fetchName = getDefinitionName(lmArgs.query);
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.begin`,
{variables: lmArgs.variables});
// Resolve document fragments before passing it to `apollo-client`.
return args.data.fetchMore({
...lmArgs,
query: resolveFragments(lmArgs.query),
})
.then((res) => {
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.success`,
{variables: lmArgs.variables, data: res.data});
return Promise.resolve(res);
})
.catch((err) => {
this.context.eventEmitter.emit(
`query.${name}.fetchMore.${fetchName}.error`,
{variables: lmArgs.variables, error: err});
throw err;
});
},
},
};
return config.props
? config.props(wrappedArgs)
: separateDataAndRoot(wrappedArgs.data);
// Return our wrapped data with a separated root.
return {...args, data: nextData, root};
},
};
@@ -128,8 +164,10 @@ export default (document, config = {}) => (WrappedComponent) => {
const reducer = withSkipOnErrors(
reducerCallbacks.reduce(
(a, b) => (prev, ...rest) =>
b(a(prev, ...rest), ...rest),
(a, b) => (prev, ...rest) => {
const next = a(prev, ...rest);
return b(next, ...rest) || next;
}
));
return {
@@ -153,4 +191,4 @@ export default (document, config = {}) => (WrappedComponent) => {
return <Wrapped {...this.props} />;
}
};
};
});
+11 -8
View File
@@ -1,24 +1,27 @@
import {Map} from 'immutable';
import * as actions from '../constants/asset';
const initialState = Map({
const initialState = {
closedAt: null,
settings: null,
title: null,
url: null,
features: Map({}),
features: {},
status: 'open',
moderation: null
});
};
export default function asset (state = initialState, action) {
switch (action.type) {
case actions.FETCH_ASSET_SUCCESS:
return state
.merge(action.asset);
return {
...state,
...action.asset,
};
case actions.UPDATE_ASSET_SETTINGS_SUCCESS:
return state
.setIn(['settings'], action.settings);
return {
...state,
settings: action.settings,
};
default:
return state;
}
+180 -101
View File
@@ -1,8 +1,7 @@
import {Map, fromJS} from 'immutable';
import * as actions from '../constants/auth';
import pym from 'coral-framework/services/pym';
const initialState = Map({
const initialState = {
isLoading: false,
loggedIn: false,
user: null,
@@ -21,27 +20,34 @@ const initialState = Map({
fromSignUp: false,
requireEmailConfirmation: false,
redirectUri: pym.parentUrl || location.href,
});
};
const purge = (user) => {
const {settings, profiles, ...userData} = user; // eslint-disable-line
return fromJS(userData);
const {settings, ...userData} = user; // eslint-disable-line
return userData;
};
export default function auth (state = initialState, action) {
switch (action.type) {
case actions.FOCUS_SIGNIN_DIALOG:
return state
.set('signInDialogFocus', true);
return {
...state,
signInDialogFocus: true,
};
case actions.BLUR_SIGNIN_DIALOG:
return state
.set('signInDialogFocus', false);
case actions.SHOW_SIGNIN_DIALOG :
return state
.set('showSignInDialog', true)
.set('signInDialogFocus', true);
return {
...state,
signInDialogFocus: false,
};
case actions.SHOW_SIGNIN_DIALOG:
return {
...state,
showSignInDialog: true,
signInDialogFocus: true,
};
case actions.HIDE_SIGNIN_DIALOG :
return state.merge(Map({
return {
...state,
isLoading: false,
showSignInDialog: false,
signInDialogFocus: false,
@@ -53,125 +59,198 @@ export default function auth (state = initialState, action) {
emailVerificationSuccess: false,
emailVerificationLoading: false,
successSignUp: false
}));
case actions.SHOW_CREATEUSERNAME_DIALOG :
return state
.set('showCreateUsernameDialog', true);
case actions.HIDE_CREATEUSERNAME_DIALOG :
return state.merge(Map({
showCreateUsernameDialog: false
}));
case actions.CREATE_USERNAME_SUCCESS :
return state.merge(Map({
};
case actions.SHOW_CREATEUSERNAME_DIALOG:
return {
...state,
showCreateUsernameDialog: true,
};
case actions.HIDE_CREATEUSERNAME_DIALOG:
return {
...state,
showCreateUsernameDialog: false,
error: ''
}));
case actions.CREATE_USERNAME_FAILURE :
return state
.set('error', action.error);
case actions.CHANGE_VIEW :
return state
.set('error', '')
.set('view', action.view);
};
case actions.CREATE_USERNAME_SUCCESS:
return {
...state,
showCreateUsernameDialog: false,
error: '',
};
case actions.CREATE_USERNAME_FAILURE:
return {
...state,
error: action.error,
};
case actions.CHANGE_VIEW:
return {
...state,
error: action.error,
view: action.view,
};
case actions.CLEAN_STATE:
return initialState;
case actions.FETCH_SIGNIN_REQUEST:
return state
.set('isLoading', true);
return {
...state,
isLoading: true,
};
case actions.CHECK_LOGIN_FAILURE:
return state
.set('checkedInitialLogin', true)
.set('loggedIn', false)
.set('user', null);
return {
...state,
checkedInitialLogin: true,
loggedIn: false,
user: null,
};
case actions.CHECK_LOGIN_SUCCESS:
return state
.set('checkedInitialLogin', true)
.set('loggedIn', true)
.set('user', purge(action.user));
return {
...state,
checkedInitialLogin: true,
loggedIn: true,
user: purge(action.user),
};
case actions.FETCH_SIGNIN_SUCCESS:
return state
.set('loggedIn', true)
.set('user', purge(action.user));
return {
...state,
loggedIn: true,
user: purge(action.user),
};
case actions.FETCH_SIGNIN_FAILURE:
return state
.set('isLoading', false)
.set('error', action.error)
.set('user', null);
return {
...state,
isLoading: false,
error: action.error,
user: null,
};
case actions.FETCH_SIGNUP_FACEBOOK_REQUEST:
return state
.set('fromSignUp', true);
return {
...state,
fromSignUp: true,
};
case actions.FETCH_SIGNIN_FACEBOOK_REQUEST:
return state
.set('fromSignUp', false);
return {
...state,
fromSignUp: false,
};
case actions.FETCH_SIGNIN_FACEBOOK_SUCCESS:
return state
.set('user', purge(action.user))
.set('loggedIn', true);
return {
...state,
loggedIn: true,
user: purge(action.user),
};
case actions.FETCH_SIGNIN_FACEBOOK_FAILURE:
return state
.set('error', action.error)
.set('user', null);
return {
...state,
error: action.error,
user: null,
};
case actions.FETCH_SIGNUP_REQUEST:
return state
.set('isLoading', true);
return {
...state,
isLoading: true,
};
case actions.FETCH_SIGNUP_FAILURE:
return state
.set('error', action.error)
.set('isLoading', false);
return {
...state,
error: action.error,
isLoading: false,
};
case actions.FETCH_SIGNUP_SUCCESS:
return state
.set('isLoading', false)
.set('successSignUp', true);
return {
...state,
isLoading: false,
successSignUp: true,
};
case actions.LOGOUT:
return state
.set('user', null)
.set('isLoading', false)
.set('loggedIn', false);
return {
...state,
user: null,
isLoading: false,
loggedIn: false,
};
case actions.INVALID_FORM:
return state
.set('error', action.error);
return {
...state,
error: action.error,
};
case actions.VALID_FORM:
return state
.set('error', '');
return {
...state,
error: '',
};
case actions.FETCH_FORGOT_PASSWORD_SUCCESS:
return state
.set('passwordRequestFailure', null)
.set('passwordRequestSuccess', 'If you have a registered account, a password reset link was sent to that email');
return {
...state,
passwordRequestFailure: null,
passwordRequestSuccess: 'If you have a registered account, a password reset link was sent to that email',
};
case actions.FETCH_FORGOT_PASSWORD_FAILURE:
return state
.set('passwordRequestFailure', 'There was an error sending your password reset email. Please try again soon!')
.set('passwordRequestSuccess', null);
return {
...state,
passwordRequestFailure: 'There was an error sending your password reset email. Please try again soon!',
passwordRequestSuccess: null,
};
case actions.UPDATE_USERNAME:
return state
.setIn(['user', 'username'], action.username);
return {
...state,
user: {
...state.user,
username: action.username,
}
};
case actions.VERIFY_EMAIL_FAILURE:
return state
.set('emailVerificationFailure', true)
.set('emailVerificationLoading', false);
return {
...state,
emailVerificationFailure: true,
emailVerificationLoading: false,
};
case actions.VERIFY_EMAIL_REQUEST:
return state.set('emailVerificationLoading', true);
return {
...state,
emailVerificationLoading: true,
};
case actions.VERIFY_EMAIL_SUCCESS:
return state
.set('emailVerificationSuccess', true)
.set('emailVerificationLoading', false);
return {
...state,
emailVerificationSuccess: true,
emailVerificationLoading: false,
};
case actions.SET_REQUIRE_EMAIL_VERIFICATION:
return state
.set('requireEmailConfirmation', action.required);
return {
...state,
requireEmailConfirmation: action.required,
};
case actions.SET_REDIRECT_URI:
return state
.set('redirectUri', action.uri);
return {
...state,
redirectUri: action.uri,
};
case 'APOLLO_SUBSCRIPTION_RESULT':
if (action.operationName === 'UserBanned' && state.getIn(['user', 'id']) === action.variables.user_id) {
return state
.mergeIn(['user'], action.result.data.userBanned);
return {
...state,
user: {
...state.user,
...action.result.data.userBanned,
},
};
}
if (action.operationName === 'UserSuspended' && state.getIn(['user', 'id']) === action.variables.user_id) {
return state
.mergeIn(['user'], action.result.data.userSuspended);
return {
...state,
user: {
...state.user,
...action.result.data.userSuspended,
},
};
}
if (action.operationName === 'UsernameRejected' && state.getIn(['user', 'id']) === action.variables.user_id) {
return state
.mergeIn(['user'], action.result.data.usernameRejected);
return {
...state,
user: {
...state.user,
...action.result.data.usernameRejected,
},
};
}
return state;
default :
-2
View File
@@ -1,11 +1,9 @@
import auth from './auth';
import user from './user';
import asset from './asset';
import {reducer as commentBox} from '../../talk-plugin-commentbox';
export default {
auth,
user,
asset,
commentBox,
};
-43
View File
@@ -1,43 +0,0 @@
import {Map} from 'immutable';
import * as authActions from '../constants/auth';
import * as actions from '../constants/user';
import * as assetActions from '../constants/assets';
const initialState = Map({
username: '',
profiles: [],
settings: {},
myComments: [],
myAssets: [], // the assets from which myComments (above) originated
});
const purge = (user) => {
const {_id, created_at, updated_at, __v, roles, ...userData} = user; // eslint-disable-line
return userData;
};
export default function user (state = initialState, action) {
switch (action.type) {
case authActions.CHECK_LOGIN_SUCCESS:
return state.merge(Map(purge(action.user)));
case authActions.CHECK_LOGIN_FAILURE:
return initialState;
case authActions.FETCH_SIGNIN_SUCCESS:
return state.merge(Map(purge(action.user)));
case authActions.FETCH_SIGNIN_FAILURE:
return initialState;
case authActions.FETCH_SIGNIN_FACEBOOK_SUCCESS:
return state.merge(Map(purge(action.user)));
case authActions.FETCH_SIGNIN_FACEBOOK_FAILURE:
return initialState;
case actions.SAVE_BIO_SUCCESS:
return state.set('settings', action.settings);
case actions.COMMENTS_BY_USER_SUCCESS:
return state.set('myComments', action.comments);
case assetActions.MULTIPLE_ASSETS_SUCCESS:
return state.set('myAssets', action.assets);
case actions.LOGOUT_SUCCESS:
return initialState;
}
return state;
}
+6
View File
@@ -1,5 +1,6 @@
import {gql} from 'react-apollo';
import t from 'coral-framework/services/i18n';
import union from 'lodash/union';
import {capitalize} from 'coral-framework/helpers/strings';
export const getTotalActionCount = (type, comment) => {
@@ -184,3 +185,8 @@ export function getSlotFragmentSpreads(slots, resource) {
export function isCommentActive(commentStatus) {
return ['NONE', 'ACCEPTED'].indexOf(commentStatus) >= 0;
}
export function getShallowChanges(a, b) {
return union(Object.keys(a), Object.keys(b))
.filter((key) => a[key] !== b[key]);
}
+14
View File
@@ -0,0 +1,14 @@
/**
* getReliability
* retrieves reliability value as string
*/
export const getReliability = (reliabilityValue) => {
if (reliabilityValue === null) {
return 'neutral';
} else if (reliabilityValue) {
return 'reliable';
} else {
return 'unreliable';
}
};
@@ -14,6 +14,7 @@ import CommentHistory from 'talk-plugin-history/CommentHistory';
import {showSignInDialog, checkLogin} from 'coral-framework/actions/auth';
import {insertCommentsSorted} from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
@@ -51,9 +52,9 @@ class ProfileContainer extends Component {
};
render() {
const {auth, asset, showSignInDialog, stopIgnoringUser} = this.props;
const {auth, auth: {user}, showSignInDialog, stopIgnoringUser, root, data} = this.props;
const {me} = this.props.root;
const loading = [1, 2, 4].indexOf(this.props.data.networkStatus) >= 0;
const loading = this.props.data.loading;
if (!auth.loggedIn) {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
@@ -63,14 +64,14 @@ class ProfileContainer extends Component {
return <Spinner />;
}
const localProfile = this.props.user.profiles.find(
const localProfile = user.profiles.find(
(p) => p.provider === 'local'
);
const emailAddress = localProfile && localProfile.id;
return (
<div>
<h2>{this.props.user.username}</h2>
<h2>{user.username}</h2>
{emailAddress ? <p>{emailAddress}</p> : null}
{me.ignoredUsers && me.ignoredUsers.length
@@ -87,13 +88,18 @@ class ProfileContainer extends Component {
<h3>{t('framework.my_comments')}</h3>
{me.comments.nodes.length
? <CommentHistory comments={me.comments} asset={asset} link={link} loadMore={this.loadMore}/>
? <CommentHistory data={data} root={root} comments={me.comments} link={link} loadMore={this.loadMore}/>
: <p>{t('user_no_comment')}</p>}
</div>
);
}
}
// TODO: This Slot should be included in `talk-plugin-history` instead.
const slots = [
'commentContent',
];
const CommentFragment = gql`
fragment TalkSettings_CommentConnectionFragment on CommentConnection {
nodes {
@@ -103,8 +109,10 @@ const CommentFragment = gql`
id
title
url
${getSlotFragmentSpreads(slots, 'asset')}
}
created_at
${getSlotFragmentSpreads(slots, 'comment')}
}
endCursor
hasNextPage
@@ -133,14 +141,13 @@ const withProfileQuery = withQuery(
...TalkSettings_CommentConnectionFragment
}
}
${getSlotFragmentSpreads(slots, 'root')}
}
${CommentFragment}
`);
const mapStateToProps = (state) => ({
user: state.user.toJS(),
asset: state.asset.toJS(),
auth: state.auth.toJS()
auth: state.auth
});
const mapDispatchToProps = (dispatch) =>
+2
View File
@@ -1,3 +1,5 @@
.root {
vertical-align: middle;
font-size: inherit;
}
@@ -133,7 +133,9 @@ export default class FlagButton extends Component {
}
handleClickOutside = () => {
this.closeMenu();
if (this.state.showMenu) {
this.closeMenu();
}
}
render () {
+41 -40
View File
@@ -7,54 +7,55 @@ import CommentContent from '../coral-embed-stream/src/components/CommentContent'
import t from 'coral-framework/services/i18n';
const Comment = (props) => {
return (
<div className={styles.myComment}>
<div>
<Slot
fill="commentContent"
defaultComponent={CommentContent}
className={`${styles.commentBody} myCommentBody`}
comment={props.comment}
/>
<p className="myCommentAsset">
<a
className={`${styles.assetURL} myCommentAnchor`}
href="#"
onClick={props.link(`${props.asset.url}`)}>
Story: {props.asset.title ? props.asset.title : props.asset.url}
</a>
</p>
</div>
<div className={styles.sidebar}>
<ul>
<li>
<a onClick={props.link(`${props.asset.url}?commentId=${props.comment.id}`)}>
<Icon name="open_in_new" className={styles.iconView}/>{t('view_conversation')}
class Comment extends React.Component {
render() {
const {comment, link, data, root} = this.props;
return (
<div className={styles.myComment}>
<div>
<Slot
fill="commentContent"
defaultComponent={CommentContent}
className={`${styles.commentBody} myCommentBody`}
data={data}
queryData={{root, comment, asset: comment.asset}}
/>
<p className="myCommentAsset">
<a
className={`${styles.assetURL} myCommentAnchor`}
href="#"
onClick={link(`${comment.asset.url}`)}>
Story: {comment.asset.title ? comment.asset.title : comment.asset.url}
</a>
</li>
<li>
<Icon name="schedule" className={styles.iconDate}/>
<PubDate
className={styles.pubdate}
created_at={props.comment.created_at}
/>
</li>
</ul>
</p>
</div>
<div className={styles.sidebar}>
<ul>
<li>
<a onClick={link(`${comment.asset.url}?commentId=${comment.id}`)}>
<Icon name="open_in_new" className={styles.iconView}/>{t('view_conversation')}
</a>
</li>
<li>
<Icon name="schedule" className={styles.iconDate}/>
<PubDate
className={styles.pubdate}
created_at={comment.created_at}
/>
</li>
</ul>
</div>
</div>
</div>
);
};
);
}
}
Comment.propTypes = {
comment: PropTypes.shape({
id: PropTypes.string,
body: PropTypes.string
}).isRequired,
asset: PropTypes.shape({
url: PropTypes.string,
title: PropTypes.string
}).isRequired
};
export default Comment;
+4 -2
View File
@@ -22,16 +22,18 @@ class CommentHistory extends React.Component {
}
render() {
const {link, comments} = this.props;
const {link, comments, data, root} = this.props;
return (
<div className={`${styles.header} commentHistory`}>
<div className="commentHistory__list">
{comments.nodes.map((comment, i) => {
return <Comment
key={i}
data={data}
root={root}
comment={comment}
link={link}
asset={comment.asset} />;
/>;
})}
</div>
{comments.hasNextPage &&
@@ -2,10 +2,11 @@ import React, {PropTypes} from 'react';
import styles from './styles.css';
import t from 'coral-framework/services/i18n';
import {BASE_PATH} from 'coral-framework/constants/url';
const ModerationLink = (props) => props.isAdmin ? (
<div className={styles.moderationLink}>
<a href={`/admin/moderate/${props.assetId}`} target="_blank">
<a href={`${BASE_PATH}admin/moderate/${props.assetId}`} target="_blank">
{t('moderate_this_stream')}
</a>
</div>
+29 -3
View File
@@ -8,6 +8,7 @@
require('env-rewrite').rewrite();
const uniq = require('lodash/uniq');
const ms = require('ms');
//==============================================================================
// CONFIG INITIALIZATION
@@ -84,6 +85,23 @@ const CONFIG = {
MONGO_URL: process.env.TALK_MONGO_URL,
REDIS_URL: process.env.TALK_REDIS_URL,
// REDIS_RECONNECTION_MAX_ATTEMPTS is the amount of attempts that a redis
// connection will attempt to reconnect before aborting with an error.
REDIS_RECONNECTION_MAX_ATTEMPTS: parseInt(process.env.TALK_REDIS_RECONNECTION_MAX_ATTEMPTS || '100'),
// REDIS_RECONNECTION_MAX_RETRY_TIME is the time in string format for the
// maximum amount of time that a client can be considered "connecting" before
// attempts at reconnection are aborted with an error.
REDIS_RECONNECTION_MAX_RETRY_TIME: ms(process.env.TALK_REDIS_RECONNECTION_MAX_RETRY_TIME || '1 min'),
// REDIS_RECONNECTION_BACKOFF_FACTOR is the factor that will be multiplied
// against the current attempt count inbetween attempts to connect to redis.
REDIS_RECONNECTION_BACKOFF_FACTOR: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_FACTOR || '500 ms'),
// REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME is the minimum time used to delay
// before attempting to reconnect to redis.
REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME: ms(process.env.TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME || '1 sec'),
//------------------------------------------------------------------------------
// Server Config
//------------------------------------------------------------------------------
@@ -94,6 +112,10 @@ const CONFIG = {
// The URL for this Talk Instance as viewable from the outside.
ROOT_URL: process.env.TALK_ROOT_URL || null,
// ROOT_URL_MOUNT_PATH when TRUE will extract the pathname from the
// TALK_ROOT_URL and use it to mount the paths on.
ROOT_URL_MOUNT_PATH: process.env.TALK_ROOT_URL_MOUNT_PATH === 'TRUE',
// The keepalive timeout (in ms) that should be used to send keep alive
// messages through the websocket to keep the socket alive.
KEEP_ALIVE: process.env.TALK_KEEP_ALIVE || '30s',
@@ -131,13 +153,17 @@ const CONFIG = {
DISABLE_AUTOFLAG_SUSPECT_WORDS: process.env.TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS === 'TRUE',
// TRUST_THRESHOLDS defines the thresholds used for automoderation.
TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:-1,-1;flag:-1,-1'
TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:2,-1;flag:2,-1'
};
//==============================================================================
// CONFIG VALIDATION
//==============================================================================
if (CONFIG.ROOT_URL_MOUNT_PATH && !CONFIG.ROOT_URL) {
throw new Error('TALK_ROOT_URL must be specified if TALK_ROOT_URL_MOUNT_PATH is set to TRUE');
}
if (process.env.NODE_ENV === 'test' && !CONFIG.ROOT_URL) {
CONFIG.ROOT_URL = 'http://localhost:3000';
} else if (!CONFIG.ROOT_URL) {
@@ -188,14 +214,14 @@ CONFIG.JWT_COOKIE_NAMES = uniq(CONFIG.JWT_COOKIE_NAMES.concat([CONFIG.JWT_COOKIE
// External database url's
//------------------------------------------------------------------------------
// Reset the mongo url in the event it hasn't been overrided and we are in a
// Reset the mongo url in the event it hasn't been overridden and we are in a
// testing environment. Every new mongo instance comes with a test database by
// default, this is consistent with common testing and use case practices.
if (process.env.NODE_ENV === 'test' && !CONFIG.MONGO_URL) {
CONFIG.MONGO_URL = 'mongodb://localhost/test';
}
// Reset the redis url in the event it hasn't been overrided and we are in a
// Reset the redis url in the event it hasn't been overridden and we are in a
// testing environment.
if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) {
CONFIG.REDIS_URL = 'redis://localhost/1';
+52 -17
View File
@@ -5,39 +5,63 @@ permalink: /docs/faq/
### How are new stories/assets added to Talk? Is there an API?
There are three ways that new assets can make their way into Talk: _just in time_, _active_ and manual.
There are three ways that new assets can make their way into Talk:
- Just in time
- Active
- Manual
#### Just in Time asset creation
Talk ships with a _just in time_ mechanism that works out of the box without integration with any CMS or manual work needed.
Talk ships with a _just in time_ mechanism that works out of the box without
integration with any CMS or manual work needed.
The _just in time_ flow looks like this:
* Request comes in for a stream on an asset that doesn't yet exist.
* Talk screens the domain against the domain whitelist, fails if doesn't pass.
* Then, concurrently
* Talk creates a new asset record and returns the stream data (which will be empty)
* Talk creates a new asset record and returns the stream data (which will be
empty)
* Schedules a job to scrape the new page and fill in asset information.
The scraping mechanism utilizes [metascraper](https://www.npmjs.com/package/metascraper) and is queued using the [Que](https://www.npmjs.com/package/kue). If your Talk deployments is configured to run separate job worker cluster, scraping will be performed by them.
The scraping mechanism utilizes
[metascraper](https://www.npmjs.com/package/metascraper) and is queued using the
[Que](https://www.npmjs.com/package/kue). If your Talk deployments is configured
to run separate job worker cluster, scraping will be performed by them.
#### Active (or push based) asset creation
If tighter CMS integration is required to push custom data into assets and/or keep data in sync as changes are made in a CMS an _active_ push based workflow must be implemented.
If tighter CMS integration is required to push custom data into assets and/or
keep data in sync as changes are made in a CMS an _active_ push based workflow
must be implemented.
This is an ideal candidate for a plugin. If you are interested in working on it, please [contact us](https://coralproject.net/contact)!
This is an ideal candidate for a plugin. If you are interested in working on it,
please [contact us](https://coralproject.net/contact)!
#### Manual asset creation
Sometimes you want to load a lot of assets into the database. The most common use case for this is populating the database during an initial installation. We recommend writing a script that transforms the data from it's source and inserts it into the _assets_ collection.
Sometimes you want to load a lot of assets into the database. The most common
use case for this is populating the database during an initial installation. We
recommend writing a script that transforms the data from it's source and inserts
it into the _assets_ collection.
For current schema information, please see the [asset model](https://github.com/coralproject/talk/blob/master/models/asset.js).
For current schema information, please see the
[asset model](https://github.com/coralproject/talk/blob/master/models/asset.js).
### Where are your http API docs?
Coral relies on GraphQL for the vast majority of it's client <-> server communication. All core queries, mutations and subscriptions are defined along with types and comments in our central [TypeDef](https://github.com/coralproject/talk/blob/master/graph/typeDefs.graphql). For plugin graph api typedefs, see each plugin's `/server/` directory.
Coral relies on GraphQL for the vast majority of it's client and server
communication. All core queries, mutations and subscriptions are defined along
with types and comments in our central
[TypeDef](https://github.com/coralproject/talk/blob/master/graph/typeDefs.graphql).
For plugin graph api typedefs, see each plugin's `/server/` directory.
In addition, Talk Server ships with [GraphiQL](https://github.com/graphql/graphiql). GraphiQL provides a full data layer IDE including interactive documentation. The autocompletes and documentation are populated from introspection meaning that Core _and plugin_ apis will be fully explorable.
In addition, Talk Server ships with
[GraphiQL](https://github.com/graphql/graphiql). GraphiQL provides a full data
layer IDE including interactive documentation. The autocomplete and
documentation are populated from introspection meaning that Core _and plugin_
apis can be explored.
To access GraphiQL:
@@ -46,22 +70,33 @@ To access GraphiQL:
### Where is documentation for a specific component?
We strive for clear inline documentation across our codebase, but have gaps. Contributions to documentation would be greatly appreciated and is a great way to start contributing to the project!
We strive for clear inline documentation across our codebase, but have gaps.
Contributions to documentation would be greatly appreciated and is a great way
to start contributing to the project!
If you are considering changing a core component (aka, one that is not in a plugin), you are entering the realm of a core developer. We strongly ask that you reach out the coral team before forking and changing core code. We are glad to help talk through your product need and come up with a strategy for implementing as a plugin, or working with you to extend the plugin API for your use case.
If you are considering changing a core component (aka, one that is not in a
plugin), you are entering the realm of a core developer. We strongly ask that
you reach out the coral team before forking and changing core code. We are glad
to help talk through your product need and come up with a strategy for
implementing as a plugin, or working with you to extend the plugin API for your
use case.
### How do I contribute to these docs?
Contributions to the docs are much appreciated and a great way to get involved in the project.
Contributions to the docs are much appreciated and a great way to get involved
in the project.
Fork the Talk repo, clone it locally (no need to go through the install from source process), then:
Fork the Talk repo, clone it locally (no need to go through the install from
source process), then:
```
cd docs
docker run --rm --volume=$PWD:/srv/jekyll -p 127.0.0.1:4000:4000 -it jekyll/jekyll:pages jekyll serve
```
You can edit the files in docs with any editor and view the live updates in a browser by hitting From the docs directory.
Then visit: [http://127.0.0.1:4000/talk/](http://127.0.0.1:4000/talk/).
You can edit the files in docs with any editor and view the live updates in a
browser by hitting From the docs directory. Then visit:
[http://127.0.0.1:4000/talk/](http://127.0.0.1:4000/talk/).
Once you've made the changes, file a PR back to the `coralproject/talk` repo.
Once you've made the changes, file a PR back to the `coralproject/talk`
repository.
+6 -5
View File
@@ -11,8 +11,9 @@ Available as [coralproject/talk](https://hub.docker.com/r/coralproject/talk/) on
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` (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)**
@@ -42,7 +43,7 @@ An example docker-compose.yml:
version: '2'
services:
talk:
image: coralproject/talk:1.5
image: coralproject/talk:latest
restart: always
ports:
- "5000:5000"
@@ -85,7 +86,7 @@ on different machines. You can achieve this easily with docker compose:
version: '2'
services:
talk-api:
image: coralproject/talk:1.5
image: coralproject/talk:latest
command: cli serve
restart: always
ports:
@@ -97,7 +98,7 @@ services:
- TALK_MONGO_URL=mongodb://mongo/talk
- TALK_REDIS_URL=redis://redis
talk-jobs:
image: coralproject/talk:1.5
image: coralproject/talk:latest
command: cli jobs process
restart: always
ports:
+1 -1
View File
@@ -36,7 +36,7 @@ git clone https://github.com/coralproject/talk.git
We now have to install the dependencies and build the static assets.
```bash
# Install package dependancies
# Install package dependencies
yarn
# Build static files
+1 -1
View File
@@ -70,7 +70,7 @@ independently. Each variety of process can always have just enough resources.
An install that heavily utilizes the jobs queue could see delays in http service
because of heavy jobs processes and/or delays in the execution of jobs processes
due to increased server load as a result of Node's single thread infrustructure.
due to increased server load as a result of Node's single thread infrastructure.
## Deployment Methodologies
+29 -7
View File
@@ -52,14 +52,36 @@ These are only used during the webpack build.
- `TALK_MONGO_URL` (*required*) - the database connection string for the MongoDB database.
- `TALK_REDIS_URL` (*required*) - the database connection string for the Redis database.
#### Advanced
- `TALK_REDIS_RECONNECTION_MAX_ATTEMPTS` (_optional_) - the amount of attempts
that a redis connection will attempt to reconnect before aborting with an
error. (Default `100`)
- `TALK_REDIS_RECONNECTION_MAX_RETRY_TIME` (_optional_) - the time in string
format for the maximum amount of time that a client can be considered
"connecting" before attempts at reconnection are aborted with an error.
(Default `1 min`)
- `TALK_REDIS_RECONNECTION_BACKOFF_FACTOR` (_optional_) - the time factor that
will be multiplied against the current attempt count inbetween attempts to
connect to redis. (Default `500 ms`)
- `TALK_REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME` (_optional_) - the minimum time
used to delay before attempting to reconnect to redis. (Default `1 sec`)
### Server
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
available in the format: `<scheme>://<host>` without the path.
available in the format: `<scheme>://<host>:<port?>/<pathname>`.
- `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`)
- `TALK_INSTALL_LOCK` (_optional for dynamic setup_) - When `TRUE`, disables the dynamic setup endpoint. (Default `FALSE`)
#### Advanced
- `TALK_ROOT_URL_MOUNT_PATH` (_optional_) - when set to `TRUE`, the routes will
be mounted onto the `<pathname>` component of the `TALK_ROOT_URL`. You would
use this when your upstream proxy cannot strip the prefix from the url.
(Default `FALSE`)
### Word Filter
- `TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS` (_optional_) When `TRUE`, disables flagging of comments that match the suspect word filter. (Default `FALSE`)
@@ -118,7 +140,7 @@ will be used:
"iss": TALK_JWT_ISSUER, // *optional* if TALK_JWT_DISABLE_ISSUER === 'TRUE', *required* otherwise
[TALK_JWT_USER_ID_CLAIM]: "<the user id>", // *required* the id of the user
// Note, if TALK_JWT_USER_ID_CLAIM contains '.', it will be used to deliniate an object, for example
// Note, if TALK_JWT_USER_ID_CLAIM contains '.', it will be used to delineate an object, for example
// `user.id` would store it like: `{user: {id}}`
}
```
@@ -151,11 +173,11 @@ order:
### Trust
Trust can automoderate comments based on user history. By specifying this
option, the beheviour can be changed to offer different results.
Trust can auto-moderate comments based on user history. By specifying this
option, the behavior can be changed to offer different results.
- `TRUST_THRESHOLDS` (_optional_) - configure the reliability thresholds for
flagging and commenting. (Default `comment:-1,-1;flag:-1,-1`)
flagging and commenting. (Default `comment:2,-1;flag:2,-1`)
The form of the environment variable:
@@ -166,10 +188,10 @@ The form of the environment variable:
The default could be read as:
- When a commenter has one comment rejected, their next comment must be
premoderated once in order to post freely again. If they instead get rejected
pre-moderated once in order to post freely again. If they instead get rejected
again, then they must have two of their comments approved in order to get
added back to the queue.
- At the moment of writing, beheviour is not attached to the flagging
- At the moment of writing, behavior is not attached to the flagging
reliability, but it is recorded.
### Cache
+1 -1
View File
@@ -67,7 +67,7 @@ for more details.
## Authentication Types
Talk also supports two methods of providing authenticationd details.
Talk also supports two methods of providing authentication details.
- Single key: this is used when your secrets do not need to be rotated.
- Multiple keys: this is used when you expect to rotate your secrets.
+1 -10
View File
@@ -6,7 +6,6 @@ permalink: /docs/architecture/client
## The Stack
- [React](#react)
- [Redux](#redux)
- [ImmutableJS](#immutablejs)
## The Architecture
@@ -32,7 +31,7 @@ It basically consist in having two types of components:
### Container Components
* __How things work__
* They dont have markup nor styles
* They provide data and behaviour to Presentational or Container Components
* They provide data and behavior to Presentational or Container Components
* They connect via `react-redux`s `connect()` to the state.
* They `mapStateToProps` the state to the Presentational Container.
* They `mapDispatchToProps` to send actions to the Presentational Container.
@@ -96,14 +95,6 @@ We use [Apollo](http://www.apollodata.com/) to handle graph requests and handle
## Redux
We use [Redux](http://redux.js.org/) to handle the auth state.
## ImmutableJS
We use Immutable JS to maintain our state immutable.
We found some really good tradeoffs while building Talk.
[How to use ImmutableJS and how we use it with Talk](https://facebook.github.io/immutable-js/docs/#/)
## Test
[How we do testing at Coral with Talk]({{ "/docs/development/tools" | absolute_url }})
+1 -1
View File
@@ -82,7 +82,7 @@ need to reconcile the plugins and build the static assets:
# get plugin dependancies and remote plugins
./bin/cli plugins reconcile
# build staic assets (including enabled client side plugins)
# build static assets (including enabled client side plugins)
yarn build
```
+1 -1
View File
@@ -236,6 +236,6 @@ Once you've taken this step, anyone can register your plugin into their Talk ser
### Publish to version control
This plugin is open source, so I'm also going to [publish it to github](https://github.com/jde/talk-plugin-asset-manager/commit/66b626caa85cb8030b3ddaa7c1a4821bf01e350a) and [cut a release](https://github.com/jde/talk-plugin-asset-manager/releases/tag/v0.1) that mirrors the npm relese.
This plugin is open source, so I'm also going to [publish it to github](https://github.com/jde/talk-plugin-asset-manager/commit/66b626caa85cb8030b3ddaa7c1a4821bf01e350a) and [cut a release](https://github.com/jde/talk-plugin-asset-manager/releases/tag/v0.1) that mirrors the npm release.
## Done!
+1 -1
View File
@@ -218,7 +218,7 @@ Basic settings can be added via json configuration in a plugin.
* Default value
* Variable name
#### Advanced Custom Configuration (low prioritiy)
#### Advanced Custom Configuration (low priority)
Users can inject configuration interfaces that they create into the configuration allowing for more advanced configuration.
+3 -3
View File
@@ -220,7 +220,7 @@ when a valid token is provided but a user can't be found in the database that
matches the provided id.
The function is async, and should return the user object that was created in the
database, or null if the user wasn't found. The `jwt` paramenter of the object
database, or null if the user wasn't found. The `jwt` parameter of the object
is the unpacked token, while `token` is the original jwt token string.
### Routes
@@ -314,14 +314,14 @@ module.exports = {
const {passport} = require('services/passport');
/**
* Facebook auth endpoint, this will redirect the user immediatly to facebook
* Facebook auth endpoint, this will redirect the user immediately to facebook
* for authorization.
*/
router.get('/facebook', passport.authenticate('facebook', {display: 'popup', authType: 'rerequest', scope: ['public_profile']}));
/**
* Facebook callback endpoint, this will send the user a html page designed to
* send back the user credentials upon sucesfull login.
* send back the user credentials upon successful login.
*/
router.get('/facebook/callback', (req, res, next) => {
+1 -1
View File
@@ -53,7 +53,7 @@ class Context {
this.plugins = decorateContextPlugins(this, contextPlugins);
// Bind the publish/subscribe to the context.
this.pubsub = pubsub.createClient();
this.pubsub = pubsub.getClient();
}
}
+4 -4
View File
@@ -26,6 +26,8 @@ const {
SUBSCRIBE_ALL_USERNAME_REJECTED,
} = require('../perms/constants');
const {BASE_PATH} = require('../url');
/**
* Plugin support requires that we merge in existing setupFunctions with our new
* plugin based ones. This allows plugins to extend existing setupFunctions as well
@@ -127,15 +129,13 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu
}),
});
const pubsubClient = pubsub.createClientFactory();
/**
* This creates a new subscription manager.
*/
const createSubscriptionManager = (server) => new SubscriptionServer({
subscriptionManager: new SubscriptionManager({
schema,
pubsub: pubsubClient(),
pubsub: pubsub.getClient(),
setupFunctions,
}),
onConnect: ({token}, connection) => {
@@ -172,7 +172,7 @@ const createSubscriptionManager = (server) => new SubscriptionServer({
keepAlive: ms(KEEP_ALIVE)
}, {
server,
path: '/api/v1/live'
path: `${BASE_PATH}api/v1/live`
});
module.exports = {
+12
View File
@@ -0,0 +1,12 @@
const pubsub = require('../services/pubsub');
// To handle dependancy injection safer, we inject the pubsub handle onto the
// request object.
module.exports = (req, res, next) => {
// Attach the pubsub handle to the requests.
req.pubsub = pubsub.getClient();
// Forward on the request.
next();
};
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "3.1.0",
"version": "3.2.0",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"scripts": {
@@ -116,7 +116,7 @@
"passport-local": "^1.0.0",
"prop-types": "^15.5.10",
"query-strings": "^0.0.1",
"react-apollo": "^1.1.0",
"react-apollo": "^1.4.12",
"react-input-autosize": "^1.1.4",
"react-recaptcha": "^2.2.6",
"react-toastify": "^1.5.0",
@@ -136,7 +136,7 @@
"yamljs": "^0.2.10"
},
"devDependencies": {
"apollo-client": "^1.0.4",
"apollo-client": "^1.9.1",
"autoprefixer": "^6.5.2",
"babel-cli": "^6.24.0",
"babel-core": "^6.24.0",
@@ -180,7 +180,6 @@
"hammerjs": "^2.0.8",
"history": "^3.0.0",
"ignore-styles": "^5.0.1",
"immutable": "^3.8.1",
"imports-loader": "^0.7.1",
"istanbul": "^1.1.0-alpha.1",
"jsdom": "^9.8.3",
+38 -8
View File
@@ -11,14 +11,29 @@ import {showSignInDialog} from 'coral-framework/actions/auth';
import {addNotification} from 'coral-framework/actions/notification';
import {capitalize} from 'coral-framework/helpers/strings';
import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils';
import hoistStatics from 'recompose/hoistStatics';
import * as PropTypes from 'prop-types';
import {getDefinitionName} from '../utils';
export default (reaction) => (WrappedComponent) => {
/*
* Disable false-positive warning below, as it doesn't work well with how we currently
* assemble the queries.
*
* Warning: fragment with name {fragment name} already exists.
* graphql-tag enforces all fragment names across your application to be unique; read more about
* this in the docs: http://dev.apollodata.com/core/fragments.html#unique-names
*/
gql.disableFragmentWarnings();
export default (reaction, options = {}) => hoistStatics((WrappedComponent) => {
if (typeof reaction !== 'string') {
console.error('Reaction must be a valid string');
return null;
}
// fragments allow the extension of the fragments defined in this HOC.
const {fragments = {}} = options;
// Global instance counter for each `reaction` type.
let instances = 0;
@@ -175,7 +190,7 @@ export default (reaction) => (WrappedComponent) => {
createdSubscription = context.client.subscribe({
query: REACTION_CREATED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
assetId: this.props.asset.id,
},
}).subscribe({
next: this.onReactionCreated,
@@ -185,7 +200,7 @@ export default (reaction) => (WrappedComponent) => {
deletedSubscription = context.client.subscribe({
query: REACTION_DELETED_SUBSCRIPTION,
variables: {
assetId: this.props.root.asset.id,
assetId: this.props.asset.id,
},
}).subscribe({
next: this.onReactionDeleted,
@@ -247,7 +262,7 @@ export default (reaction) => (WrappedComponent) => {
}
render() {
const {comment} = this.props;
const {root, asset, comment} = this.props;
const reactionSummary = getMyActionSummary(
`${Reaction}ActionSummary`,
@@ -262,10 +277,12 @@ export default (reaction) => (WrappedComponent) => {
const alreadyReacted = !!reactionSummary;
return <WrappedComponent
root={root}
asset={asset}
comment={comment}
showSignInDialog={this.props.showSignInDialog}
addNotification={this.props.addNotification}
user={this.props.user}
comment={comment}
reactionSummary={reactionSummary}
count={count}
alreadyReacted={alreadyReacted}
@@ -363,7 +380,7 @@ export default (reaction) => (WrappedComponent) => {
);
const mapStateToProps = (state) => ({
user: state.auth.toJS().user,
user: state.auth.user,
});
const mapDispatchToProps = (dispatch) =>
@@ -371,9 +388,19 @@ export default (reaction) => (WrappedComponent) => {
const enhance = compose(
withFragments({
...fragments,
asset: gql`
fragment ${Reaction}Button_asset on Asset {
id
${fragments.asset ? `...${getDefinitionName(fragments.asset)}` : ''}
}
${fragments.asset ? fragments.asset : ''}
`,
comment: gql`
fragment ${Reaction}Button_comment on Comment {
id
action_summaries {
__typename
... on ${Reaction}ActionSummary {
count
current_user {
@@ -381,7 +408,10 @@ export default (reaction) => (WrappedComponent) => {
}
}
}
}`
${fragments.comment ? `...${getDefinitionName(fragments.comment)}` : ''}
}
${fragments.comment ? fragments.comment : ''}
`
}),
connect(mapStateToProps, mapDispatchToProps),
withDeleteReaction,
@@ -391,4 +421,4 @@ export default (reaction) => (WrappedComponent) => {
WithReactions.displayName = `WithReactions(${getDisplayName(WrappedComponent)})`;
return enhance(WithReactions);
};
});
+35 -6
View File
@@ -8,13 +8,28 @@ import {withAddTag, withRemoveTag} from 'coral-framework/graphql/mutations';
import withFragments from 'coral-framework/hocs/withFragments';
import {addNotification} from 'coral-framework/actions/notification';
import {forEachError, isTagged} from 'coral-framework/utils';
import hoistStatics from 'recompose/hoistStatics';
import {getDefinitionName} from '../utils';
export default (tag) => (WrappedComponent) => {
/*
* Disable false-positive warning below, as it doesn't work well with how we currently
* assemble the queries.
*
* Warning: fragment with name {fragment name} already exists.
* graphql-tag enforces all fragment names across your application to be unique; read more about
* this in the docs: http://dev.apollodata.com/core/fragments.html#unique-names
*/
gql.disableFragmentWarnings();
export default (tag, options = {}) => hoistStatics((WrappedComponent) => {
if (typeof tag !== 'string') {
console.error('Tag must be a valid string');
return null;
}
// fragments allow the extension of the fragments defined in this HOC.
const {fragments = {}} = options;
const Tag = capitalize(tag);
const TAG = tag.toUpperCase();
@@ -68,13 +83,15 @@ export default (tag) => (WrappedComponent) => {
}
render() {
const {comment, user, config} = this.props;
const {root, asset, comment, user, config} = this.props;
const alreadyTagged = isTagged(comment.tags, TAG);
return <WrappedComponent
user={user}
root={root}
asset={asset}
comment={comment}
user={user}
alreadyTagged={alreadyTagged}
postTag={this.postTag}
deleteTag={this.deleteTag}
@@ -84,7 +101,7 @@ export default (tag) => (WrappedComponent) => {
}
const mapStateToProps = (state) => ({
user: state.auth.toJS().user,
user: state.auth.user,
});
const mapDispatchToProps = (dispatch) =>
@@ -92,14 +109,26 @@ export default (tag) => (WrappedComponent) => {
const enhance = compose(
withFragments({
...fragments,
asset: gql`
fragment ${Tag}Button_asset on Asset {
id
${fragments.asset ? `...${getDefinitionName(fragments.asset)}` : ''}
}
${fragments.asset ? fragments.asset : ''}
`,
comment: gql`
fragment ${Tag}Button_comment on Comment {
id
tags {
tag {
name
}
}
}`
${fragments.comment ? `...${getDefinitionName(fragments.comment)}` : ''}
}
${fragments.comment ? fragments.comment : ''}
`
}),
connect(mapStateToProps, mapDispatchToProps),
withAddTag,
@@ -109,4 +138,4 @@ export default (tag) => (WrappedComponent) => {
WithTags.displayName = `WithTags(${getDisplayName(WrappedComponent)})`;
return enhance(WithTags);
};
});
@@ -122,7 +122,7 @@ class ChangeUsernameContainer extends React.Component {
}
const mapStateToProps = ({auth}) => ({
auth: auth.toJS()
auth: auth
});
const mapDispatchToProps = (dispatch) =>
@@ -32,7 +32,7 @@ const CreateUsernameDialog = ({
className={styles.fakeComment}
username={formData.username}
created_at={Date.now()}
comment={{body:t('createdisplay.fake_comment_body')}}
body={t('createdisplay.fake_comment_body')}
/>
<p className={styles.ifyoudont}>
{t('createdisplay.if_you_dont_change_your_name')}
@@ -0,0 +1,37 @@
.root {
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
margin-bottom: 10px;
padding: 8px 0px 10px 0px;
position: relative;
}
.body {
}
.footer {
display: flex;
justify-content: space-between;
}
.button {
color: #2a2a2a;
margin: 5px 10px 5px 0px;
background: none;
padding: 0px;
border: none;
font-size: inherit;
vertical-align: middle;
&:hover {
color: #767676;
cursor: pointer;
}
}
.icon {
font-size: 12px;
padding: 0 2px 0 5px;
vertical-align: middle;
}
@@ -2,65 +2,43 @@ import React from 'react';
import t from 'coral-framework/services/i18n';
import {ReplyButton} from 'talk-plugin-replies';
import PubDate from 'talk-plugin-pubdate/PubDate';
import Slot from 'coral-framework/components/Slot';
import AuthorName from 'talk-plugin-author-name/AuthorName';
import styles from 'coral-embed-stream/src/components/Comment.css';
import styles from './FakeComment.css';
import {Icon} from 'plugin-api/beta/client/components/ui';
export const FakeComment = ({username, created_at, comment}) => (
<div className={`comment ${styles.Comment}`} style={{marginLeft: 0 * 30}}>
<hr aria-hidden={true} />
<AuthorName author={{name: username}} />
export const FakeComment = ({username, created_at, body}) => (
<div className={styles.root}>
<AuthorName author={{username}} />
<PubDate created_at={created_at} />
<Slot comment={comment} />
<div className="commentActionsLeft comment__action-container">
<div className={`${'talk-plugin-likes'}-container`}>
<button className={'talk-plugin-likes-button'}>
<span className={'talk-plugin-likes-button-text'}>
<div className={styles.body}>
{body}
</div>
<div className={styles.footer}>
<div>
<button className={styles.button}>
<span className={styles.label}>
{t('like')}
</span>
<i
className={`${'talk-plugin-likes'}-icon material-icons`}
aria-hidden={true}
>
thumb_up
</i>
<Icon name="thumb_up" className={styles.icon} />
</button>
<ReplyButton
onClick={() => {}}
parentCommentId={'commentID'}
currentUserId={{}}
/>
</div>
<ReplyButton
onClick={() => {}}
parentCommentId={'commentID'}
currentUserId={{}}
/>
</div>
<div className="commentActionsRight comment__action-container">
<div className="talk-plugin-permalinks-container">
<button className="talk-plugin-permalinks-button">
<span
className={`comment__action-button comment__action-button--nowrap ${'talk-plugin-flags'}-button-text`}
>
<div>
<button className={styles.button}>
<span className={styles.label}>
{t('permalink')}
</span>
<i
className="talk-plugin-permalinks-icon material-icons"
aria-hidden={true}
>
link
</i>
<Icon name="link" className={styles.icon} />
</button>
</div>
<div className={`${'talk-plugin-flags'}-container`}>
<button className={`${'talk-plugin-flags'}-button`}>
<span
className={`comment__action-button comment__action-button--nowrap ${'talk-plugin-flags'}-button-text`}
>
<button className={styles.button}>
<span className={styles.label}>
{t('report')}
</span>
<i
className={`${'talk-plugin-flags'}-icon material-icons`}
aria-hidden={true}
>
flag
</i>
<Icon name="flag" className={styles.icon} />
</button>
</div>
</div>
@@ -16,7 +16,7 @@ const SignInButton = ({loggedIn, showSignInDialog}) => (
);
const mapStateToProps = ({auth}) => ({
loggedIn: auth.toJS().loggedIn
loggedIn: auth.loggedIn
});
const mapDispatchToProps = (dispatch) =>
@@ -176,7 +176,7 @@ class SignInContainer extends React.Component {
}
const mapStateToProps = (state) => ({
auth: state.auth.toJS()
auth: state.auth
});
const mapDispatchToProps = (dispatch) =>
@@ -22,8 +22,8 @@ const UserBox = ({loggedIn, user, logout, onShowProfile}) => (
);
const mapStateToProps = ({auth}) => ({
loggedIn: auth.toJS().loggedIn,
user: auth.toJS().user
loggedIn: auth.loggedIn,
user: auth.user
});
const mapDispatchToProps = (dispatch) =>
@@ -31,7 +31,6 @@ export default withFragments({
name
}
}
user {
id
username
@@ -91,7 +91,7 @@ const COMMENT_UNFEATURED_SUBSCRIPTION = gql`
`;
const mapStateToProps = (state) => ({
user: state.auth.toJS().user,
user: state.auth.user,
});
export default connect(mapStateToProps, null)(ModSubscription);
@@ -1,5 +1,16 @@
import ModTag from '../components/ModTag';
import {withTags} from 'plugin-api/beta/client/hocs';
import {gql} from 'react-apollo';
export default withTags('featured')(ModTag);
const fragments = {
comment: gql`
fragment TalkFeaturedComments_ModTag_comment on Comment {
user {
username
}
}
`
};
export default withTags('featured', {fragments})(ModTag);
@@ -16,8 +16,8 @@ class TabPaneContainer extends React.Component {
query: LOAD_MORE_QUERY,
variables: {
limit: 5,
cursor: this.props.root.asset.featuredComments.endCursor,
asset_id: this.props.root.asset.id,
cursor: this.props.asset.featuredComments.endCursor,
asset_id: this.props.asset.id,
sort: 'REVERSE_CHRONOLOGICAL',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
@@ -0,0 +1,15 @@
import {gql} from 'react-apollo';
import Tag from '../components/Tag';
import {withFragments} from 'plugin-api/beta/client/hocs';
export default withFragments({
comment: gql`
fragment TalkFeaturedComments_Tag_comment on Comment {
tags {
tag {
name
}
}
}
`
})(Tag);
@@ -1,5 +1,5 @@
import Tab from './containers/Tab';
import Tag from './components/Tag';
import Tag from './containers/Tag';
import Button from './components/Button';
import TabPane from './containers/TabPane';
import translations from './translations.yml';
@@ -26,6 +26,9 @@ export default {
IgnoreUser: ({variables}) => ({
updateQueries: {
CoralEmbedStream_Embed: (previous) => {
if (!previous.asset.featuredComments) {
return previous;
}
const ignoredUserId = variables.id;
const newNodes = previous.asset.featuredComments.nodes.filter((n) => n.user.id !== ignoredUserId);
const removedCount = previous.asset.featuredComments.nodes.length - newNodes.length;

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