diff --git a/.gitignore b/.gitignore index e8dfd14e7..a619f0988 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,6 @@ plugins/* !plugins/coral-plugin-facebook-auth !plugins/coral-plugin-respect !plugins/coral-plugin-offtopic +!plugins/coral-plugin-like **/node_modules/* diff --git a/Dockerfile b/Dockerfile index 73885d628..5c7b3cabb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,8 @@ EXPOSE 5000 COPY . /usr/src/app # Install app dependencies and build static assets. -RUN yarn install --frozen-lockfile && \ +RUN yarn global add node-gyp && \ + yarn install --frozen-lockfile && \ cli plugins reconcile && \ yarn build && \ yarn install --production && \ diff --git a/INSTALL.md b/INSTALL.md index 49033acc8..aed9cbad8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -243,7 +243,7 @@ file under the `scripts` key including: # Setup Once you've installed Talk (either via Docker or source), you still need to -setup the application. If you are unfamiliar with any terminoligy used in the +setup the application. If you are unfamiliar with any terminology used in the setup process, refer to the `TERMINOLOGY.md` document. ## Via Web diff --git a/PLUGINS.md b/PLUGINS.md index 068a0886f..0d17ccd55 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -145,6 +145,10 @@ type RootMutation { type RootQuery { people: [Person!] } + +type Subscription { + leader: Person +} ``` Thanks to [gql-merge](https://www.npmjs.com/package/gql-merge) the contents of @@ -259,6 +263,23 @@ If your post function accepts four parameters, then it can modify the field result. It is *required* that the function resolves a promise (or returns) with the modified value or simply the original if you didn't modify it. +#### Field: `setupFunctions` + +```js +setupFunctions: { + leader: (options, args) => ({ + leader: { + filter: (person) => person.place === 1 + }, + }), +} +``` + +Setup functions allow you to create filters that control which pubsub.publish() events +send data to the client. If the type in question contains args, clients may subscribe using those arguments to further filter their subscription. + +For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions). + #### Field: `router` ```js @@ -375,6 +396,10 @@ module.exports = { type RootQuery { people: [Person!] } + + type Subscription { + leader: Person + } `, context: { Slack: () => ({ @@ -430,6 +455,13 @@ module.exports = { } } } + }, + setupFunctions: { + leader: (options, args) => ({ + leader: { + filter: (person) => person.place === 1 + } + } } }; diff --git a/app.js b/app.js index e1e864b10..a0a5b7789 100644 --- a/app.js +++ b/app.js @@ -5,13 +5,11 @@ const path = require('path'); const helmet = require('helmet'); const {passport} = require('./services/passport'); const plugins = require('./services/plugins'); -const session = require('express-session'); const enabled = require('debug').enabled; -const RedisStore = require('connect-redis')(session); -const redis = require('./services/redis'); const csrf = require('csurf'); const errors = require('./errors'); -const graph = require('./graph'); +const session = require('./services/session'); +const {createGraphOptions} = require('./graph'); const apollo = require('graphql-server-express'); const app = express(); @@ -43,34 +41,7 @@ app.set('view engine', 'ejs'); // SESSION MIDDLEWARE //============================================================================== -const session_opts = { - secret: process.env.TALK_SESSION_SECRET, - httpOnly: true, - rolling: true, - saveUninitialized: true, - resave: true, - unset: 'destroy', - name: 'talk.sid', - cookie: { - secure: false, - maxAge: 8.64e+7, // 24 hours for session token expiry - }, - store: new RedisStore({ - client: redis.createClient(), - }) -}; - -if (app.get('env') === 'production') { - - // Enable the secure cookie when we are in production mode. - session_opts.cookie.secure = true; -} else if (app.get('env') === 'test') { - - // Add in the secret during tests. - session_opts.secret = 'keyboard cat'; -} - -app.use(session(session_opts)); +app.use(session); //============================================================================== // PASSPORT MIDDLEWARE @@ -96,7 +67,7 @@ app.use(passport.session()); //============================================================================== // GraphQL endpoint. -app.use('/api/v1/graph/ql', apollo.graphqlExpress(graph.createGraphOptions)); +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') { diff --git a/bin/cli-serve b/bin/cli-serve index 08b451904..eca23303b 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -1,13 +1,14 @@ #!/usr/bin/env node -const app = require('../app'); const program = require('./commander'); -const http = require('http'); +const app = require('../app'); +const {createServer} = require('http'); const scraper = require('../services/scraper'); const mailer = require('../services/mailer'); const kue = require('../services/kue'); const mongoose = require('../services/mongoose'); const util = require('./util'); +const {createSubscriptionManager} = require('../graph/subscriptions'); /** * Get port from environment and store in Express. @@ -20,7 +21,7 @@ app.set('port', port); /** * Create HTTP server. */ -const server = http.createServer(app); +const server = createServer(app); /** * Event listener for HTTP server "error" event. @@ -76,20 +77,29 @@ function normalizePort(val) { function onListening() { let addr = server.address(); let bind = typeof addr === 'string' - ? `pipe ${ addr}` - : `port ${ addr.port}`; - console.log(`Listening on ${ bind}`); + ? `pipe ${addr}` + : `port ${addr.port}`; + console.log(`API Server Listening on ${bind}`); } /** * Start the app. */ -function startApp() { +function startApp(program) { /** * Listen on provided port, on all network interfaces. */ - server.listen(port); + server.listen(port, () => { + + // Mount the websocket server if requested. + if (program.websockets) { + console.log(`Websocket Server Listening on ${port}`); + + // Mount the subscriptions server on the application server. + createSubscriptionManager(server); + } + }); server.on('error', onError); server.on('listening', onListening); } @@ -100,10 +110,11 @@ function startApp() { program .option('-j, --jobs', 'enable job processing on this thread') + .option('-w, --websockets', 'enable the websocket (subscriptions) handler on this thread') .parse(process.argv); // Start the application serving. -startApp(); +startApp(program); // Enable job processing on the thread if enabled. if (program.jobs) { diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index bfb2e197c..af3423acc 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -42,6 +42,9 @@ const routes = ( + + + diff --git a/client/coral-admin/src/components/ActionButton.js b/client/coral-admin/src/components/ActionButton.js index 1fcafeac0..3651ee939 100644 --- a/client/coral-admin/src/components/ActionButton.js +++ b/client/coral-admin/src/components/ActionButton.js @@ -3,9 +3,15 @@ import styles from './ModerationList.css'; import {Button} from 'coral-ui'; import {menuActionsMap} from '../containers/ModerationQueue/helpers/moderationQueueActionsMap'; -const ActionButton = ({type = '', status, ...props}) => { +const ActionButton = ({type = '', active, ...props}) => { const typeName = type.toLowerCase(); - const active = ((type === 'REJECT' && status === 'REJECTED') || (type === 'APPROVE' && status === 'ACCEPTED')); + let text = menuActionsMap[type].text; + + if (text === 'Approve' && active) { + text = 'Approved'; + } else if (text === 'Reject' && active) { + text = 'Rejected'; + } return ( + >{text} ); }; ActionButton.propTypes = { - status: PropTypes.string + active: PropTypes.bool }; export default ActionButton; diff --git a/client/coral-admin/src/components/ModerationKeysModal.js b/client/coral-admin/src/components/ModerationKeysModal.js index 3d43c621d..55bee3082 100644 --- a/client/coral-admin/src/components/ModerationKeysModal.js +++ b/client/coral-admin/src/components/ModerationKeysModal.js @@ -28,6 +28,8 @@ const shortcuts = [ export default class ModerationKeysModal extends React.Component { static propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, hideShortcutsNote: PropTypes.func.isRequired, shortcutsNoteVisible: PropTypes.string.isRequired } diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index f09a9f9f7..6418efb10 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -6,7 +6,7 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; import {Logo} from './Logo'; -const CoralHeader = ({handleLogout, restricted = false}) => ( +const CoralHeader = ({handleLogout, showShortcuts = () => {}, restricted = false}) => (
{ @@ -55,7 +55,8 @@ const CoralHeader = ({handleLogout, restricted = false}) => (
- Sign Out + showShortcuts(true)}>{lang.t('configure.shortcuts')} + {lang.t('configure.sign-out')}
@@ -72,6 +73,7 @@ const CoralHeader = ({handleLogout, restricted = false}) => ( ); CoralHeader.propTypes = { + showShortcuts: PropTypes.func, handleLogout: PropTypes.func.isRequired, restricted: PropTypes.bool // hide elemnts from a user that's logged out }; diff --git a/client/coral-admin/src/components/ui/Layout.js b/client/coral-admin/src/components/ui/Layout.js index c8b5bf57a..6bf9661b7 100644 --- a/client/coral-admin/src/components/ui/Layout.js +++ b/client/coral-admin/src/components/ui/Layout.js @@ -4,9 +4,13 @@ import Header from './Header'; import Drawer from './Drawer'; import styles from './Layout.css'; -const Layout = ({children, handleLogout = () => {}, restricted = false, ...props}) => ( +const Layout = ({children, handleLogout = () => {}, toggleShortcutModal, restricted = false, ...props}) => ( -
+
{children} @@ -16,6 +20,7 @@ const Layout = ({children, handleLogout = () => {}, restricted = false, ...props Layout.propTypes = { handleLogout: PropTypes.func, + toggleShortcutModal: PropTypes.func, restricted: PropTypes.bool // hide elements from a user that's logged out }; diff --git a/client/coral-admin/src/containers/Community/People.js b/client/coral-admin/src/containers/Community/People.js index 0011cd704..e375bac38 100644 --- a/client/coral-admin/src/containers/Community/People.js +++ b/client/coral-admin/src/containers/Community/People.js @@ -4,7 +4,6 @@ import translations from 'coral-admin/src/translations.json'; import styles from './Community.css'; import Table from './Table'; -import Loading from './Loading'; import {Pager, Icon} from 'coral-ui'; import EmptyCard from '../../components/EmptyCard'; @@ -29,8 +28,8 @@ const tableHeaders = [ } ]; -const People = ({isFetching, commenters, searchValue, onSearchChange, ...props}) => { - const hasResults = !isFetching && !!commenters.length; +const People = ({commenters, searchValue, onSearchChange, ...props}) => { + const hasResults = !!commenters.length; return (
@@ -47,7 +46,6 @@ const People = ({isFetching, commenters, searchValue, onSearchChange, ...props})
- { isFetching && } { hasResults ? { ? assets.map(asset => { let flagSummary = null; if (asset.action_summaries) { - flagSummary = asset.action_summaries.find(s => s.type === 'FlagAssetActionSummary'); + flagSummary = asset.action_summaries.find(s => s.__typename === 'FlagAssetActionSummary'); } return ( diff --git a/client/coral-admin/src/containers/LayoutContainer.js b/client/coral-admin/src/containers/LayoutContainer.js index 30e32a828..42e2baade 100644 --- a/client/coral-admin/src/containers/LayoutContainer.js +++ b/client/coral-admin/src/containers/LayoutContainer.js @@ -2,6 +2,7 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import Layout from '../components/ui/Layout'; import {checkLogin, handleLogin, logout, requestPasswordReset} from '../actions/auth'; +import {toggleModal as toggleShortcutModal} from '../actions/moderation'; import {fetchConfig} from '../actions/config'; import {FullLoading} from '../components/FullLoading'; import AdminLogin from '../components/AdminLogin'; @@ -23,7 +24,7 @@ class LayoutContainer extends Component { passwordRequestSuccess } = this.props.auth; - const {handleLogout, TALK_RECAPTCHA_PUBLIC} = this.props; + const {handleLogout, toggleShortcutModal, TALK_RECAPTCHA_PUBLIC} = this.props; if (loadingUser) { return ; } if (!isAdmin) { return ; } - if (isAdmin && loggedIn) { return ; } + if (isAdmin && loggedIn) { + return ; + } return ; } } @@ -49,6 +52,7 @@ const mapDispatchToProps = dispatch => ({ fetchConfig: () => dispatch(fetchConfig()), handleLogin: (username, password, recaptchaResponse) => dispatch(handleLogin(username, password, recaptchaResponse)), requestPasswordReset: email => dispatch(requestPasswordReset(email)), + toggleShortcutModal: toggle => dispatch(toggleShortcutModal(toggle)), handleLogout: () => dispatch(logout()) }); diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index dffa4de26..7a3aab909 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -5,7 +5,7 @@ import key from 'keymaster'; import isEqual from 'lodash/isEqual'; import styles from './components/styles.css'; -import {modQueueQuery} from '../../graphql/queries'; +import {modQueueQuery, getQueueCounts} from '../../graphql/queries'; import {banUser, setCommentStatus} from '../../graphql/mutations'; import {fetchSettings} from 'actions/settings'; @@ -138,6 +138,9 @@ class ModerationContainer extends Component { case 'all': activeTabCount = data.allCount; break; + case 'accepted': + activeTabCount = data.acceptedCount; + break; case 'premod': activeTabCount = data.premodCount; break; @@ -155,6 +158,7 @@ class ModerationContainer extends Component { ({ export default compose( connect(mapStateToProps, mapDispatchToProps), setCommentStatus, + getQueueCounts, modQueueQuery, banUser )(ModerationContainer); diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js index f986b947b..c4bf0f85c 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js @@ -9,51 +9,67 @@ import translations from 'coral-admin/src/translations'; import LoadMore from './components/LoadMore'; const lang = new I18n(translations); -const ModerationQueue = ({comments, selectedIndex, commentCount, singleView, loadMore, activeTab, sort, ...props}) => { - return ( -
-
    - { - comments.length - ? comments.map((comment, i) => { - const status = comment.action_summaries ? 'FLAGGED' : comment.status; - return ; - }) - : {lang.t('modqueue.emptyqueue')} - } -
- -
- ); -}; +class ModerationQueue extends React.Component { -ModerationQueue.propTypes = { - bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired, - suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, - currentAsset: PropTypes.object, - showBanUserDialog: PropTypes.func.isRequired, - rejectComment: PropTypes.func.isRequired, - acceptComment: PropTypes.func.isRequired, - comments: PropTypes.array.isRequired -}; + static propTypes = { + bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired, + suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, + currentAsset: PropTypes.object, + showBanUserDialog: PropTypes.func.isRequired, + rejectComment: PropTypes.func.isRequired, + acceptComment: PropTypes.func.isRequired, + comments: PropTypes.array.isRequired + } + + componentDidUpdate (prev) { + const {loadMore, comments, commentCount, sort, activeTab: tab, assetId: asset_id} = this.props; + + // if the user just moderated the last (visible) comment + // AND there are more comments available on the server, + // go ahead and load more comments + if (prev.comments.length > 0 && comments.length === 0 && commentCount > 0) { + loadMore({sort, tab, asset_id}); + } + } + + render () { + const {comments, selectedIndex, commentCount, singleView, loadMore, activeTab, sort, ...props} = this.props; + + return ( +
+
    + { + comments.length + ? comments.map((comment, i) => { + const status = comment.action_summaries ? 'FLAGGED' : comment.status; + return ; + }) + : {lang.t('modqueue.emptyqueue')} + } +
+ +
+ ); + } +} export default ModerationQueue; diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js index e611c4bda..e5afd9726 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js @@ -31,7 +31,7 @@ const Comment = ({actions = [], comment, ...props}) => { } return ( -
  • +
  • @@ -66,15 +66,17 @@ const Comment = ({actions = [], comment, ...props}) => {
    {links ? Contains Link : null}
    - {actions.map((action, i) => - props.acceptComment({commentId: comment.id})} - rejectComment={() => props.rejectComment({commentId: comment.id})} - /> - )} + {actions.map((action, i) => { + const active = (action === 'REJECT' && comment.status === 'REJECTED') || + (action === 'APPROVE' && comment.status === 'ACCEPTED'); + return props.acceptComment({commentId: comment.id})} + rejectComment={() => props.rejectComment({commentId: comment.id})} />; + })}
    diff --git a/client/coral-admin/src/containers/ModerationQueue/components/CommentCount.js b/client/coral-admin/src/containers/ModerationQueue/components/CommentCount.js index 14cf8ecfc..0517caf0f 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/CommentCount.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/CommentCount.js @@ -1,9 +1,25 @@ import React, {PropTypes} from 'react'; import styles from './CommentCount.css'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from 'coral-admin/src/translations.json'; +const lang = new I18n(translations); -const CommentCount = props => ( - {props.count} -); +const CommentCount = ({count}) => { + let number = count; + + // shorten large counts to abbreviations + if (number / 1e9 > 1) { + number = `${(number / 1e9).toFixed(1)}${lang.t('modqueue.billion')}`; + } else if (number / 1e6 > 1) { + number = `${(number / 1e6).toFixed(1)}${lang.t('modqueue.million')}`; + } else if (number / 1e3 > 1) { + number = `${(number / 1e3).toFixed(1)}${lang.t('modqueue.thousand')}`; + } + + return ( + {number} + ); +}; CommentCount.propTypes = { count: PropTypes.number.isRequired diff --git a/client/coral-admin/src/containers/ModerationQueue/components/LoadMore.js b/client/coral-admin/src/containers/ModerationQueue/components/LoadMore.js index 71b6b9e37..c90d3cd14 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/LoadMore.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/LoadMore.js @@ -7,13 +7,16 @@ const LoadMore = ({comments, loadMore, sort, tab, assetId, showLoadMore}) => { showLoadMore && } @@ -23,7 +26,7 @@ LoadMore.propTypes = { comments: PropTypes.array.isRequired, loadMore: PropTypes.func.isRequired, sort: PropTypes.oneOf(['CHRONOLOGICAL', 'REVERSE_CHRONOLOGICAL']).isRequired, - tab: PropTypes.oneOf(['rejected', 'premod', 'flagged', 'all']).isRequired, + tab: PropTypes.oneOf(['rejected', 'premod', 'flagged', 'all', 'accepted']).isRequired, assetId: PropTypes.string, showLoadMore: PropTypes.bool.isRequired }; diff --git a/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js b/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js index 032aa740e..39594274e 100644 --- a/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js +++ b/client/coral-admin/src/containers/ModerationQueue/components/ModerationMenu.js @@ -10,7 +10,7 @@ import {Link} from 'react-router'; const lang = new I18n(translations); const ModerationMenu = ( - {asset, allCount, premodCount, rejectedCount, flaggedCount, selectSort, sort} + {asset, allCount, acceptedCount, premodCount, rejectedCount, flaggedCount, selectSort, sort} ) => { function getPath (type) { @@ -28,6 +28,12 @@ const ModerationMenu = ( activeClassName={styles.active}> {lang.t('modqueue.all')} + + {lang.t('modqueue.approved')} + ({ acceptComment: ({commentId}) => { @@ -54,6 +55,21 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, { }, updateQueries: { ModQueue: (oldData) => { + const comment = views.reduce((comment, view) => { + return comment ? comment : oldData[view].find(c => c.id === commentId); + }, null); + let accepted; + let acceptedCount = oldData.acceptedCount; + + // if the comment was already in the Approved queue, don't re-add it + if (comment.status === 'ACCEPTED') { + accepted = [...oldData.accepted]; + } else { + comment.status = 'ACCEPTED'; + acceptedCount++; + accepted = [comment, ...oldData.accepted]; + } + const premod = oldData.premod.filter(c => c.id !== commentId); const flagged = oldData.flagged.filter(c => c.id !== commentId); const rejected = oldData.rejected.filter(c => c.id !== commentId); @@ -63,11 +79,13 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, { return { ...oldData, - premodCount, - flaggedCount, - rejectedCount, + premodCount: Math.max(0, premodCount), + flaggedCount: Math.max(0, flaggedCount), + acceptedCount: Math.max(0, acceptedCount), + rejectedCount: Math.max(0, rejectedCount), premod, flagged, + accepted, rejected, }; } @@ -82,21 +100,37 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, { }, updateQueries: { ModQueue: (oldData) => { - const comment = oldData.premod.concat(oldData.flagged).filter(c => c.id === commentId)[0]; - const rejected = [comment].concat(oldData.rejected); + const comment = views.reduce((comment, view) => { + return comment ? comment : oldData[view].find(c => c.id === commentId); + }, null); + let rejected; + let rejectedCount = oldData.rejectedCount; + + // if the item was already in the Rejected queue, don't put it in again + if (comment.status === 'REJECTED') { + rejected = oldData.rejected; + } else { + comment.status = 'REJECTED'; + rejectedCount++; + rejected = [comment, ...oldData.rejected]; + } + const premod = oldData.premod.filter(c => c.id !== commentId); const flagged = oldData.flagged.filter(c => c.id !== commentId); + const accepted = oldData.accepted.filter(c => c.id !== commentId); const premodCount = premod.length < oldData.premod.length ? oldData.premodCount - 1 : oldData.premodCount; const flaggedCount = flagged.length < oldData.flagged.length ? oldData.flaggedCount - 1 : oldData.flaggedCount; - const rejectedCount = oldData.rejectedCount + 1; + const acceptedCount = accepted.length < oldData.accepted.length ? oldData.acceptedCount - 1 : oldData.acceptedCount; return { ...oldData, - premodCount, - flaggedCount, - rejectedCount, + premodCount: Math.max(0, premodCount), + flaggedCount: Math.max(0, flaggedCount), + acceptedCount: Math.max(0, acceptedCount), + rejectedCount: Math.max(0, rejectedCount), premod, flagged, + accepted, rejected }; } diff --git a/client/coral-admin/src/graphql/queries/getQueueCounts.graphql b/client/coral-admin/src/graphql/queries/getQueueCounts.graphql new file mode 100644 index 000000000..4a7c00d87 --- /dev/null +++ b/client/coral-admin/src/graphql/queries/getQueueCounts.graphql @@ -0,0 +1,22 @@ +query Counts ($asset_id: ID) { + allCount: commentCount(query: { + asset_id: $asset_id + }) + acceptedCount: commentCount(query: { + statuses: [ACCEPTED], + asset_id: $asset_id + }) + premodCount: commentCount(query: { + statuses: [PREMOD], + asset_id: $asset_id + }) + rejectedCount: commentCount(query: { + statuses: [REJECTED], + asset_id: $asset_id + }) + flaggedCount: commentCount(query: { + action_type: FLAG, + asset_id: $asset_id, + statuses: [NONE, PREMOD] + }) +} diff --git a/client/coral-admin/src/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js index 53113558b..f26c8464e 100644 --- a/client/coral-admin/src/graphql/queries/index.js +++ b/client/coral-admin/src/graphql/queries/index.js @@ -4,6 +4,7 @@ import MOD_QUEUE_QUERY from './modQueueQuery.graphql'; import MOD_QUEUE_LOAD_MORE from './loadMore.graphql'; import MOD_USER_FLAGGED_QUERY from './modUserFlaggedQuery.graphql'; import METRICS from './metricsQuery.graphql'; +import GET_QUEUE_COUNTS from './getQueueCounts.graphql'; export const modQueueQuery = graphql(MOD_QUEUE_QUERY, { options: ({params: {id = null}}) => { @@ -33,31 +34,34 @@ export const getMetrics = graphql(METRICS, { } }); -export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) => { - let statuses; +export const loadMore = (fetchMore) => ({limit = 10, cursor, sort, tab, asset_id}) => { + let variables = { + limit, + cursor, + sort, + asset_id + }; switch(tab) { case 'all': - statuses = null; + variables.statuses = null; + break; + case 'accepted': + variables.statuses = ['ACCEPTED']; break; case 'premod': - statuses = ['PREMOD']; + variables.statuses = ['PREMOD']; break; case 'flagged': - statuses = ['NONE', 'PREMOD']; + variables.statuses = ['NONE', 'PREMOD']; + variables.action_type = 'FLAG'; break; case 'rejected': - statuses = ['REJECTED']; + variables.statuses = ['REJECTED']; break; } return fetchMore({ query: MOD_QUEUE_LOAD_MORE, - variables: { - limit, - cursor, - sort, - statuses, - asset_id - }, + variables, updateQuery: (oldData, {fetchMoreResult:{comments}}) => { return { ...oldData, @@ -90,3 +94,14 @@ export const modQueueResort = (id, fetchMore) => (sort) => { updateQuery: (oldData, {fetchMoreResult:{data}}) => data }); }; + +export const getQueueCounts = graphql(GET_QUEUE_COUNTS, { + options: ({params: {id = null}}) => { + return { + pollInterval: 5000, + variables: { + asset_id: id + } + }; + } +}); diff --git a/client/coral-admin/src/graphql/queries/loadMore.graphql b/client/coral-admin/src/graphql/queries/loadMore.graphql index 56966a804..a42c7b2bf 100644 --- a/client/coral-admin/src/graphql/queries/loadMore.graphql +++ b/client/coral-admin/src/graphql/queries/loadMore.graphql @@ -1,7 +1,7 @@ #import "../fragments/commentView.graphql" -query LoadMoreModQueue($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!]) { - comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort}) { +query LoadMoreModQueue($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) { + comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort, action_type: $action_type}) { ...commentView action_summaries { count diff --git a/client/coral-admin/src/graphql/queries/modQueueQuery.graphql b/client/coral-admin/src/graphql/queries/modQueueQuery.graphql index f124b87b5..80dec5ff7 100644 --- a/client/coral-admin/src/graphql/queries/modQueueQuery.graphql +++ b/client/coral-admin/src/graphql/queries/modQueueQuery.graphql @@ -8,6 +8,13 @@ query ModQueue ($asset_id: ID, $sort: SORT_ORDER) { }) { ...commentView } + accepted: comments(query: { + statuses: [ACCEPTED], + asset_id: $asset_id, + sort: $sort + }) { + ...commentView + } premod: comments(query: { statuses: [PREMOD], asset_id: $asset_id, @@ -38,6 +45,10 @@ query ModQueue ($asset_id: ID, $sort: SORT_ORDER) { allCount: commentCount(query: { asset_id: $asset_id }) + acceptedCount: commentCount(query: { + statuses: [ACCEPTED], + asset_id: $asset_id + }) premodCount: commentCount(query: { statuses: [PREMOD], asset_id: $asset_id diff --git a/client/coral-admin/src/services/fragmentMatcher.js b/client/coral-admin/src/services/fragmentMatcher.js index daa01a538..531708f31 100644 --- a/client/coral-admin/src/services/fragmentMatcher.js +++ b/client/coral-admin/src/services/fragmentMatcher.js @@ -7,12 +7,37 @@ const fm = new IntrospectionFragmentMatcher({ introspectionQueryResultData: { __schema: { types: [ + { + kind: 'INTERFACE', + name: 'UserError', + possibleTypes: [ + {name: 'GenericUserError'}, + {name: 'ValidationUserError'} + ] + }, + { + kind: 'INTERFACE', + name: 'Response', + possibleTypes: [ + {name: 'CreateCommentResponse'}, + {name: 'CreateFlagResponse'}, + {name: 'CreateDontAgreeResponse'}, + {name: 'DeleteActionResponse'}, + {name: 'SetUserStatusResponse'}, + {name: 'SuspendUserResponse'}, + {name: 'SetCommentStatusResponse'}, + {name: 'AddCommentTagResponse'}, + {name: 'RemoveCommentTagResponse'}, + {name: 'IgnoreUserResponse'}, + {name: 'StopIgnoringUserResponse'} + ] + }, { kind: 'INTERFACE', name: 'Action', possibleTypes: [ + {name: 'DefaultAction'}, {name: 'FlagAction'}, - {name: 'LikeAction'}, {name: 'DontAgreeAction'} ], }, @@ -20,10 +45,18 @@ const fm = new IntrospectionFragmentMatcher({ kind: 'INTERFACE', name: 'ActionSummary', possibleTypes: [ + {name: 'DefaultActionSummary'}, {name: 'FlagActionSummary'}, - {name: 'LikeActionSummary'}, {name: 'DontAgreeActionSummary'} ], + }, + { + kind: 'INTERFACE', + name: 'AssetActionSummary', + possibleTypes: [ + {name: 'DefaultAssetActionSummary'}, + {name: 'FlagAssetActionSummary'}, + ] } ], }, diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json index 9e167232e..3f9392656 100644 --- a/client/coral-admin/src/translations.json +++ b/client/coral-admin/src/translations.json @@ -36,6 +36,7 @@ "modqueue": { "likes": "likes", "all": "all", + "approved": "approved", "premod": "pre-mod", "rejected": "rejected", "flagged": "flagged", @@ -62,7 +63,10 @@ "impersonating": "Impersonating", "offensive": "Offensive", "spam/ads": "Spam/Ads", - "other": "Other" + "other": "Other", + "thousand": "k", + "million": "M", + "billion": "B" }, "comment": { "flagged": "flagged", @@ -79,6 +83,8 @@ "copy": "Copy to Clipboard" }, "configure": { + "sign-out": "Sign Out", + "shortcuts": "Shortcuts", "closed-stream-settings": "Closed Stream Message", "open-stream-configuration": "This comment stream is currently open. By closing this comment stream, no new comments may be submitted and all previous comments will still be displayed.", "close-stream-configuration": "This comment stream is currently closed. By opening this comment stream, new comments may be submitted and displayed", @@ -225,6 +231,8 @@ "loading": "Cargando resultados" }, "modqueue": { + "all": "todos", + "approved": "aprobado", "likes": "gustos", "premod": "pre-mod", "rejected": "rechazado", @@ -243,7 +251,10 @@ "impersonating": "Suplantación", "offensive": "Ofensivo", "spam/ads": "Spam/Propaganda", - "other": "Otros" + "other": "Otros", + "thousand": "m", + "million": "M", + "billion": "B" }, "comment": { "flagged": "marcado", @@ -257,6 +268,8 @@ "username_flags": "marcas para este nombre de usuario" }, "configure": { + "sign-out": "Desconectar", + "shortcuts": "Atajos", "closed-stream-settings": "Mensaje a enviar cuando los comentarios están cerrados en el artículo", "open-stream-configuration": "Este hilo de comentarios esta abierto. Al cerrarlo, ningún nuevo comentario será publicado y todos los comentarios anteriores serán mostrados.", "close-stream-configuration": "Este hilo de comentario está en este momento cerrado. Al abrirlo, nuevos comentarios serán publicaods y mostrados.", diff --git a/client/coral-embed-stream/src/AppRouter.js b/client/coral-embed-stream/src/AppRouter.js index 133a3790d..e2553889c 100644 --- a/client/coral-embed-stream/src/AppRouter.js +++ b/client/coral-embed-stream/src/AppRouter.js @@ -1,7 +1,7 @@ import React from 'react'; import {Router, Route, browserHistory} from 'react-router'; -import Embed from './Embed'; +import Embed from './containers/Embed'; import SignInContainer from 'coral-sign-in/containers/SignInContainer'; const routes = ( diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js deleted file mode 100644 index ba25b5b87..000000000 --- a/client/coral-embed-stream/src/Embed.js +++ /dev/null @@ -1,353 +0,0 @@ -import React from 'react'; -import {compose} from 'react-apollo'; -import {connect} from 'react-redux'; -import isEqual from 'lodash/isEqual'; -import I18n from 'coral-framework/modules/i18n/i18n'; -import translations from 'coral-framework/translations'; -const lang = new I18n(translations); - -import {TabBar, Tab, TabContent, Spinner, Button} from 'coral-ui'; - -const {logout, showSignInDialog, requestConfirmEmail, openSignInPopUp, checkLogin} = authActions; -const {addNotification, clearNotification} = notificationActions; -const {fetchAssetSuccess} = assetActions; -import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments'; - -import {queryStream} from 'coral-framework/graphql/queries'; -import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser, editComment} from 'coral-framework/graphql/mutations'; -import {editName} from 'coral-framework/actions/user'; -import {updateCountCache, viewAllComments} from 'coral-framework/actions/asset'; -import {notificationActions, authActions, assetActions, pym} from 'coral-framework'; - -import Stream from './Stream'; -import InfoBox from 'coral-plugin-infobox/InfoBox'; -import QuestionBox from 'coral-plugin-questionbox/QuestionBox'; -import {ModerationLink} from 'coral-plugin-moderation'; -import Count from 'coral-plugin-comment-count/CommentCount'; -import CommentBox from 'coral-plugin-commentbox/CommentBox'; -import UserBox from 'coral-sign-in/components/UserBox'; -import SuspendedAccount from 'coral-framework/components/SuspendedAccount'; -import ChangeUsernameContainer from '../../coral-sign-in/containers/ChangeUsernameContainer'; -import ProfileContainer from 'coral-settings/containers/ProfileContainer'; -import RestrictedContent from 'coral-framework/components/RestrictedContent'; -import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer'; -import HighlightedComment from './Comment'; -import LoadMore from './LoadMore'; -import NewCount from './NewCount'; - -class Embed extends React.Component { - - constructor(props) { - super(props); - this.state = { - activeTab: 0, - showSignInDialog: false, - activeReplyBox: '' - }; - } - - changeTab = (tab) => { - - // Everytime the comes from another tab, the Stream needs to be updated. - if (tab === 0) { - this.props.viewAllComments(); - this.props.data.refetch(); - } - - this.setState({ - activeTab: tab - }); - } - - static propTypes = { - data: React.PropTypes.shape({ - loading: React.PropTypes.bool, - error: React.PropTypes.object - }).isRequired, - - // dispatch action to add a tag to a comment - addCommentTag: React.PropTypes.func, - - // dispatch action to remove a tag from a comment - removeCommentTag: React.PropTypes.func, - - // dispatch action to ignore another user - ignoreUser: React.PropTypes.func, - - // edit a comment, passed (id, { body }) - editComment: React.PropTypes.func, - } - - componentDidMount () { - pym.sendMessage('childReady'); - this.props.checkLogin(); - } - - componentWillUnmount () { - clearInterval(this.state.countPoll); - } - - componentWillReceiveProps (nextProps) { - const {loadAsset} = this.props; - if(!isEqual(nextProps.data.asset, this.props.data.asset)) { - loadAsset(nextProps.data.asset); - - const {getCounts, updateCountCache, asset: {countCache}} = this.props; - const {asset} = nextProps.data; - - if (!countCache) { - updateCountCache(asset.id, asset.commentCount); - } - - this.setState({ - countPoll: setInterval(() => { - const {asset} = this.props.data; - getCounts({ - asset_id: asset.id, - limit: asset.comments.length, - sort: 'REVERSE_CHRONOLOGICAL' - }); - }, NEW_COMMENT_COUNT_POLL_INTERVAL) - }); - } - } - - componentDidUpdate(prevProps) { - if(!isEqual(prevProps.data.comment, this.props.data.comment)) { - - // Scroll to a permalinked comment if one is in the URL once the page is done rendering. - setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0); - } - } - - setActiveReplyBox = (reactKey) => { - if (!this.props.auth.user) { - this.props.showSignInDialog(); - } else { - this.setState({activeReplyBox: reactKey}); - } - } - - render () { - const {activeTab} = this.state; - const {closedAt, countCache = {}} = this.props.asset; - const {asset, refetch, comment} = this.props.data; - const {loggedIn, isAdmin, user, showSignInDialog} = this.props.auth; - - // even though the permalinked comment is the highlighted one, we're displaying its parent + replies - const highlightedComment = comment && comment.parent ? comment.parent : comment; - - const openStream = closedAt === null; - - const banned = user && user.status === 'BANNED'; - - const hasOlderComments = !!( - asset && - asset.lastComment && - asset.lastComment.id !== asset.comments[asset.comments.length - 1].id - ); - - const expandForLogin = showSignInDialog ? { - minHeight: document.body.scrollHeight + 200 - } : {}; - - if (!asset) { - return ; - } - - // Find the created_at date of the first comment. If no comments exist, set the date to a week ago. - const firstCommentDate = asset.comments[0] - ? asset.comments[0].created_at - : new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(); - - const userBox = this.props.logout().then(refetch)} changeTab={this.changeTab}/>; - - // TODO: This is a quickfix and will be replaced after our refactor. - const ignoredUsers = this.props.userData.ignoredUsers; - const commentIsIgnored = (comment) => ignoredUsers && ignoredUsers.includes(comment.user.id); - - return ( -
    -
    - - - {lang.t('myProfile')} - Configure Stream - - { - highlightedComment && - - } - - { loggedIn ? userBox : null } - { - openStream - ?
    - - - - }> - { - user - ? - : null - } - -
    - :

    {asset.settings.closedMessage}

    - } - - {!loggedIn && } - - {loggedIn && user && } - {loggedIn && } - - {/* the highlightedComment is isolated after the user followed a permalink */} - { - highlightedComment - ? - :
    - -
    - -
    - -
    - } -
    - - - - - - { loggedIn ? userBox : null } - - - -
    -
    - ); - } -} - -const mapStateToProps = state => ({ - auth: state.auth.toJS(), - userData: state.user.toJS(), - asset: state.asset.toJS(), -}); - -const mapDispatchToProps = dispatch => ({ - requestConfirmEmail: () => dispatch(requestConfirmEmail()), - loadAsset: (asset) => dispatch(fetchAssetSuccess(asset)), - addNotification: (type, text) => addNotification(type, text), - clearNotification: () => dispatch(clearNotification()), - editName: (username) => dispatch(editName(username)), - showSignInDialog: () => dispatch(showSignInDialog()), - updateCountCache: (id, count) => dispatch(updateCountCache(id, count)), - viewAllComments: () => dispatch(viewAllComments()), - logout: () => dispatch(logout()), - openSignInPopUp: cb => dispatch(openSignInPopUp(cb)), - checkLogin: () => dispatch(checkLogin()), - dispatch: d => dispatch(d), -}); - -export default compose( - connect(mapStateToProps, mapDispatchToProps), - postComment, - postFlag, - postLike, - postDontAgree, - addCommentTag, - removeCommentTag, - ignoreUser, - deleteAction, - editComment, - queryStream, -)(Embed); diff --git a/client/coral-embed-stream/src/Stream.js b/client/coral-embed-stream/src/Stream.js deleted file mode 100644 index a1b07f5ca..000000000 --- a/client/coral-embed-stream/src/Stream.js +++ /dev/null @@ -1,107 +0,0 @@ -import React, {PropTypes} from 'react'; -import Comment from './Comment'; -import IgnoredCommentTombstone from './IgnoredCommentTombstone'; - -class Stream extends React.Component { - - static propTypes = { - addNotification: PropTypes.func.isRequired, - postItem: PropTypes.func.isRequired, - asset: PropTypes.object.isRequired, - open: PropTypes.bool.isRequired, - comments: PropTypes.array.isRequired, - currentUser: PropTypes.shape({ - username: PropTypes.string, - id: PropTypes.string - }), - - charCountEnable: PropTypes.bool.isRequired, - maxCharCount: PropTypes.number, - - // dispatch action to add a tag to a comment - addCommentTag: PropTypes.func, - - // dispatch action to remove a tag from a comment - removeCommentTag: PropTypes.func, - - // dispatch action to ignore another user - ignoreUser: React.PropTypes.func, - - // list of user ids that should be rendered as ignored - ignoredUsers: React.PropTypes.arrayOf(React.PropTypes.string), - - // edit a comment, passed (id, { body }) - editComment: React.PropTypes.func, - } - - constructor(props) { - super(props); - this.state = {activeReplyBox: '', countPoll: null}; - } - - render () { - const { - comments, - currentUser, - asset, - postItem, - addNotification, - postFlag, - postLike, - open, - postDontAgree, - loadMore, - deleteAction, - showSignInDialog, - addCommentTag, - removeCommentTag, - pluginProps, - ignoreUser, - ignoredUsers, - charCountEnable, - maxCharCount, - } = this.props; - const commentIsIgnored = (comment) => ignoredUsers && ignoredUsers.includes(comment.user.id); - return ( -
    - { - comments.map(comment => - commentIsIgnored(comment) - ? - : - ) - } -
    - ); - } -} - -export default Stream; diff --git a/client/coral-embed-stream/src/actions/embed.js b/client/coral-embed-stream/src/actions/embed.js new file mode 100644 index 000000000..863494c68 --- /dev/null +++ b/client/coral-embed-stream/src/actions/embed.js @@ -0,0 +1,10 @@ +import * as actions from '../constants/embed'; +import {viewAllComments} from './stream'; + +export const setActiveTab = (tab) => (dispatch, getState) => { + dispatch({type: actions.SET_ACTIVE_TAB, tab}); + if (getState().stream.commentId) { + dispatch(viewAllComments()); + } +}; + diff --git a/client/coral-embed-stream/src/actions/stream.js b/client/coral-embed-stream/src/actions/stream.js new file mode 100644 index 000000000..81b57a0fe --- /dev/null +++ b/client/coral-embed-stream/src/actions/stream.js @@ -0,0 +1,40 @@ +import {pym} from 'coral-framework'; +import * as actions from '../constants/stream'; + +export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id}); +export const setCommentCountCache = (amount) => ({type: actions.SET_COMMENT_COUNT_CACHE, amount}); + +function removeParam(key, sourceURL) { + let rtn = sourceURL.split('?')[0]; + let param; + let params_arr = []; + let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : ''; + if (queryString !== '') { + params_arr = queryString.split('&'); + for (let i = params_arr.length - 1; i >= 0; i -= 1) { + param = params_arr[i].split('=')[0]; + if (param === key) { + params_arr.splice(i, 1); + } + } + rtn = `${rtn}?${params_arr.join('&')}`; + } + return rtn; +} + +export const viewAllComments = () => { + + // remove the comment_id url param + const modifiedUrl = removeParam('comment_id', location.href); + + try { + + // "window" here refers to the embedded iframe + window.history.replaceState({}, document.title, modifiedUrl); + + // also change the parent url + pym.sendMessage('coral-view-all-comments'); + } catch (e) { /* not sure if we're worried about old browsers */ } + + return {type: actions.VIEW_ALL_COMMENTS}; +}; diff --git a/client/coral-embed-stream/src/Comment.css b/client/coral-embed-stream/src/components/Comment.css similarity index 100% rename from client/coral-embed-stream/src/Comment.css rename to client/coral-embed-stream/src/components/Comment.css diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/components/Comment.js similarity index 76% rename from client/coral-embed-stream/src/Comment.js rename to client/coral-embed-stream/src/components/Comment.js index ef40c2a64..cda85f49d 100644 --- a/client/coral-embed-stream/src/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -1,11 +1,3 @@ -// this component will -// render its children -// render a like button -// render a permalink button -// render a reply button -// render a flag button -// translate things? - import React, {PropTypes} from 'react'; import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton'; @@ -16,27 +8,35 @@ import Content from 'coral-plugin-commentcontent/CommentContent'; import PubDate from 'coral-plugin-pubdate/PubDate'; import {ReplyBox, ReplyButton} from 'coral-plugin-replies'; import FlagComment from 'coral-plugin-flags/FlagComment'; -import LikeButton from 'coral-plugin-likes/LikeButton'; -import {BestButton, IfUserCanModifyBest, BEST_TAG, commentIsBest, BestIndicator} from 'coral-plugin-best/BestButton'; -import LoadMore from 'coral-embed-stream/src/LoadMore'; +import { + BestButton, + IfUserCanModifyBest, + BEST_TAG, + commentIsBest, + BestIndicator +} from 'coral-plugin-best/BestButton'; import Slot from 'coral-framework/components/Slot'; +import LoadMore from './LoadMore'; import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import {TopRightMenu} from './TopRightMenu'; -import {getActionSummary, getTotalActionCount, iPerformedThisAction} from 'coral-framework/utils'; import classnames from 'classnames'; import {EditableCommentContent} from './EditableCommentContent'; +import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils'; import styles from './Comment.css'; -const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF') ; +const isStaff = tags => !tags.every(t => t.name !== 'STAFF'); -// hold actions links (e.g. Like, Reply) along the comment footer +// hold actions links (e.g. Reply) along the comment footer const ActionButton = ({children}) => { - return { children }; + return ( + + {children} + + ); }; class Comment extends React.Component { - constructor(props) { super(props); @@ -60,7 +60,6 @@ class Comment extends React.Component { setActiveReplyBox: PropTypes.func.isRequired, showSignInDialog: PropTypes.func.isRequired, postFlag: PropTypes.func.isRequired, - postLike: PropTypes.func.isRequired, deleteAction: PropTypes.func.isRequired, parentId: PropTypes.string, highlighted: PropTypes.string, @@ -91,7 +90,8 @@ class Comment extends React.Component { PropTypes.shape({ body: PropTypes.string.isRequired, id: PropTypes.string.isRequired - })), + }) + ), user: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired @@ -155,7 +155,6 @@ class Comment extends React.Component { postItem, addNotification, showSignInDialog, - postLike, highlighted, postFlag, postDontAgree, @@ -169,12 +168,14 @@ class Comment extends React.Component { disableReply, commentIsIgnored, maxCharCount, - charCountEnable, + charCountEnable } = this.props; - const likeSummary = getActionSummary('LikeActionSummary', comment); const flagSummary = getActionSummary('FlagActionSummary', comment); - const dontAgreeSummary = getActionSummary('DontAgreeActionSummary', comment); + const dontAgreeSummary = getActionSummary( + 'DontAgreeActionSummary', + comment + ); let myFlag = null; if (iPerformedThisAction('FlagActionSummary', comment)) { myFlag = flagSummary.find(s => s.current_user); @@ -182,46 +183,60 @@ class Comment extends React.Component { myFlag = dontAgreeSummary.find(s => s.current_user); } - let commentClass = parentId ? `reply ${styles.Reply}` : `comment ${styles.Comment}`; + let commentClass = parentId + ? `reply ${styles.Reply}` + : `comment ${styles.Comment}`; commentClass += comment.id === 'pending' ? ` ${styles.pendingComment}` : ''; // call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar) - const notifyOnError = (fn, errorToMessage) => async function (...args) { - if (typeof errorToMessage !== 'function') {errorToMessage = (error) => error.message;} - try { - return await fn(...args); - } catch (error) { - addNotification('error', errorToMessage(error)); - throw error; - } - }; + const notifyOnError = (fn, errorToMessage) => + async function(...args) { + if (typeof errorToMessage !== 'function') { + errorToMessage = error => error.message; + } + try { + return await fn(...args); + } catch (error) { + addNotification('error', errorToMessage(error)); + throw error; + } + }; - const addBestTag = notifyOnError(() => addCommentTag({ - id: comment.id, - tag: BEST_TAG, - }), () => 'Failed to tag comment as best'); + const addBestTag = notifyOnError( + () => + addCommentTag({ + id: comment.id, + tag: BEST_TAG + }), + () => 'Failed to tag comment as best' + ); - const removeBestTag = notifyOnError(() => removeCommentTag({ - id: comment.id, - tag: BEST_TAG, - }), () => 'Failed to remove best comment tag'); + const removeBestTag = notifyOnError( + () => + removeCommentTag({ + id: comment.id, + tag: BEST_TAG + }), + () => 'Failed to remove best comment tag' + ); return (
    + style={{marginLeft: depth * 30}} + >
    -
    - - { isStaff(comment.tags) - ? Staff - : null } +
    + + {isStaff(comment.tags) ? Staff : null} - { commentIsBest(comment) + {commentIsBest(comment) ? - : null } + : null } + { @@ -230,7 +245,15 @@ class Comment extends React.Component { : null } - + + { (currentUser && (comment.user.id === currentUser.id)) @@ -266,40 +289,47 @@ class Comment extends React.Component { parentId={parentId} stopEditing={() => this.setState({isEditing: false})} /> - : + :
    + + +
    } - +
    - - {/* TODO implmement iPerformedThisAction for the like */} - - - { - !disableReply && + + {!disableReply && setActiveReplyBox(comment.id)} parentCommentId={parentId || comment.id} currentUserId={currentUser && currentUser.id} - banned={false} /> - - } + banned={false} + /> + } + removeBest={removeBestTag} + /> - +
    @@ -315,12 +345,12 @@ class Comment extends React.Component { postDontAgree={postDontAgree} deleteAction={deleteAction} showSignInDialog={showSignInDialog} - currentUser={currentUser} /> + currentUser={currentUser} + />
    - { - activeReplyBox === comment.id + {activeReplyBox === comment.id ? { setActiveReplyBox(''); @@ -332,15 +362,16 @@ class Comment extends React.Component { addNotification={addNotification} authorId={currentUser.id} postItem={postItem} - assetId={asset.id} /> - : null - } - { - comment.replies && + assetId={asset.id} + /> + : null} + {comment.replies && comment.replies.map(reply => { return commentIsIgnored(reply) ? : ; - }) - } - { - comment.replies && -
    + comment={reply} + />; + })} + {comment.replies && +
    comment.replies.length} - loadMore={loadMore}/> -
    - } + loadMore={loadMore} + /> +
    }
    ); } diff --git a/client/coral-embed-stream/src/EditableCommentContent.js b/client/coral-embed-stream/src/components/EditableCommentContent.js similarity index 100% rename from client/coral-embed-stream/src/EditableCommentContent.js rename to client/coral-embed-stream/src/components/EditableCommentContent.js diff --git a/client/coral-embed-stream/src/components/Embed.js b/client/coral-embed-stream/src/components/Embed.js new file mode 100644 index 000000000..0d26cf740 --- /dev/null +++ b/client/coral-embed-stream/src/components/Embed.js @@ -0,0 +1,87 @@ +import React from 'react'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from 'coral-framework/translations'; +const lang = new I18n(translations); + +import {TabBar, Tab, TabContent, Button} from 'coral-ui'; + +import Stream from '../containers/Stream'; +import Count from 'coral-plugin-comment-count/CommentCount'; +import UserBox from 'coral-sign-in/components/UserBox'; +import ProfileContainer from 'coral-settings/containers/ProfileContainer'; +import RestrictedContent from 'coral-framework/components/RestrictedContent'; +import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer'; + +export default class Embed extends React.Component { + changeTab = (tab) => { + switch(tab) { + case 0: + this.props.setActiveTab('stream'); + break; + case 1: + this.props.setActiveTab('profile'); + + // TODO: move data fetching to profile container. + this.props.data.refetch(); + break; + case 2: + this.props.setActiveTab('config'); + + // TODO: move data fetching to config container. + this.props.data.refetch(); + break; + } + } + + handleShowProfile = () => this.props.setActiveTab('profile'); + + render () { + const {activeTab, logout, viewAllComments, commentId} = this.props; + const {asset: {totalCommentCount}} = this.props.root; + const {loggedIn, isAdmin, user} = this.props.auth; + + const userBox = ; + + return ( +
    +
    + + + {lang.t('myProfile')} + Configure Stream + + { + commentId && + + } + + { loggedIn ? userBox : null } + + + + + + + + { loggedIn ? userBox : null } + + + +
    +
    + ); + } +} + +Embed.propTypes = { + data: React.PropTypes.shape({ + loading: React.PropTypes.bool, + error: React.PropTypes.object + }).isRequired, +}; diff --git a/client/coral-embed-stream/src/IgnoreUserWizard.js b/client/coral-embed-stream/src/components/IgnoreUserWizard.js similarity index 100% rename from client/coral-embed-stream/src/IgnoreUserWizard.js rename to client/coral-embed-stream/src/components/IgnoreUserWizard.js diff --git a/client/coral-embed-stream/src/IgnoredCommentTombstone.js b/client/coral-embed-stream/src/components/IgnoredCommentTombstone.js similarity index 100% rename from client/coral-embed-stream/src/IgnoredCommentTombstone.js rename to client/coral-embed-stream/src/components/IgnoredCommentTombstone.js diff --git a/client/coral-embed-stream/src/LoadMore.js b/client/coral-embed-stream/src/components/LoadMore.js similarity index 95% rename from client/coral-embed-stream/src/LoadMore.js rename to client/coral-embed-stream/src/components/LoadMore.js index 1a58c8d19..8c665a2db 100644 --- a/client/coral-embed-stream/src/LoadMore.js +++ b/client/coral-embed-stream/src/components/LoadMore.js @@ -1,7 +1,7 @@ import React, {PropTypes} from 'react'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from 'coral-framework/translations.json'; -import {ADDTL_COMMENTS_ON_LOAD_MORE} from 'coral-framework/constants/comments'; +import {ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream'; import {Button} from 'coral-ui'; const lang = new I18n(translations); diff --git a/client/coral-embed-stream/src/NewCount.js b/client/coral-embed-stream/src/components/NewCount.js similarity index 80% rename from client/coral-embed-stream/src/NewCount.js rename to client/coral-embed-stream/src/components/NewCount.js index 8548e6a14..a006ee802 100644 --- a/client/coral-embed-stream/src/NewCount.js +++ b/client/coral-embed-stream/src/components/NewCount.js @@ -3,9 +3,9 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from 'coral-framework/translations.json'; const lang = new I18n(translations); -const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, updateCountCache}) => (e) => { +const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, setCommentCountCache}) => (e) => { e.preventDefault(); - updateCountCache(assetId, commentCount); + setCommentCountCache(commentCount); loadMore({ asset_id: assetId, limit: 500, @@ -15,11 +15,11 @@ const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, upd }; const NewCount = (props) => { - const newComments = props.commentCount - props.countCache; + const newComments = props.commentCount - props.commentCountCache; return
    { - props.countCache && newComments > 0 ? + props.commentCountCache && newComments > 0 ? } + {loggedIn && + user && + } + {loggedIn && } + + {/* the highlightedComment is isolated after the user followed a permalink */} + {highlightedComment + ? + :
    + +
    + {comments.map( + comment => + (commentIsIgnored(comment) + ? + : ) + )} +
    + +
    } +
    + ); + } +} + +Stream.propTypes = { + addNotification: PropTypes.func.isRequired, + postItem: PropTypes.func.isRequired, + + // dispatch action to add a tag to a comment + addCommentTag: PropTypes.func, + + // dispatch action to remove a tag from a comment + removeCommentTag: PropTypes.func, + + // dispatch action to ignore another user + ignoreUser: React.PropTypes.func, + + // edit a comment, passed (id, { body }) + editComment: React.PropTypes.func, +}; + +export default Stream; diff --git a/client/coral-embed-stream/src/TopRightMenu.css b/client/coral-embed-stream/src/components/TopRightMenu.css similarity index 100% rename from client/coral-embed-stream/src/TopRightMenu.css rename to client/coral-embed-stream/src/components/TopRightMenu.css diff --git a/client/coral-embed-stream/src/TopRightMenu.js b/client/coral-embed-stream/src/components/TopRightMenu.js similarity index 100% rename from client/coral-embed-stream/src/TopRightMenu.js rename to client/coral-embed-stream/src/components/TopRightMenu.js diff --git a/client/coral-embed-stream/src/constants/embed.js b/client/coral-embed-stream/src/constants/embed.js new file mode 100644 index 000000000..178e5d6e4 --- /dev/null +++ b/client/coral-embed-stream/src/constants/embed.js @@ -0,0 +1 @@ +export const SET_ACTIVE_TAB = 'SET_ACTIVE_TAB'; diff --git a/client/coral-embed-stream/src/constants/stream.js b/client/coral-embed-stream/src/constants/stream.js new file mode 100644 index 000000000..cb17edb2f --- /dev/null +++ b/client/coral-embed-stream/src/constants/stream.js @@ -0,0 +1,5 @@ +export const SET_ACTIVE_REPLY_BOX = 'SET_ACTIVE_REPLY_BOX'; +export const SET_COMMENT_COUNT_CACHE = 'SET_COMMENT_COUNT_CACHE'; +export const ADDTL_COMMENTS_ON_LOAD_MORE = 10; +export const NEW_COMMENT_COUNT_POLL_INTERVAL = 20000; +export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS'; diff --git a/client/coral-embed-stream/src/containers/Comment.js b/client/coral-embed-stream/src/containers/Comment.js new file mode 100644 index 000000000..41fcbeafa --- /dev/null +++ b/client/coral-embed-stream/src/containers/Comment.js @@ -0,0 +1,52 @@ +import {gql} from 'react-apollo'; +import Comment from '../components/Comment'; +import withFragments from 'coral-framework/hocs/withFragments'; +import {getSlotsFragments} from 'coral-framework/helpers/plugins'; + +const pluginFragments = getSlotsFragments([ + 'streamQuestionArea', + 'commentInputArea', + 'commentInputDetailArea', + 'commentInfoBar', + 'commentActions', + 'commentContent', + 'commentReactions' +]); + +export default withFragments({ + root: gql` + fragment Comment_root on RootQuery { + __typename + ${pluginFragments.spreads('root')} + } + ${pluginFragments.definitions('root')} + `, + comment: gql` + fragment Comment_comment on Comment { + id + body + created_at + status + tags { + name + } + user { + id + name: username + } + action_summaries { + __typename + count + current_user { + id + } + } + editing { + edited + editableUntil + } + ${pluginFragments.spreads('comment')} + } + ${pluginFragments.definitions('comment')} + ` +})(Comment); diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js new file mode 100644 index 000000000..754191c9b --- /dev/null +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -0,0 +1,177 @@ +import React from 'react'; +import {compose, gql, graphql} from 'react-apollo'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import isEqual from 'lodash/isEqual'; +import branch from 'recompose/branch'; +import renderComponent from 'recompose/renderComponent'; +import update from 'immutability-helper'; + +import {Spinner} from 'coral-ui'; +import {authActions, assetActions, pym} from 'coral-framework'; +import {getDefinitionName, separateDataAndRoot} from 'coral-framework/utils'; +import Embed from '../components/Embed'; +import {setCommentCountCache, viewAllComments} from '../actions/stream'; +import {setActiveTab} from '../actions/embed'; +import Stream from './Stream'; + +const {logout, checkLogin} = authActions; +const {fetchAssetSuccess} = assetActions; + +class EmbedContainer extends React.Component { + + componentDidMount() { + pym.sendMessage('childReady'); + } + + componentWillReceiveProps(nextProps) { + if(this.props.root.me && !nextProps.root.me) { + + // Refetch because on logout `excludeIgnored` becomes `false`. + // TODO: logout via mutation and obsolete this? + this.props.data.refetch(); + } + + const {fetchAssetSuccess} = this.props; + if(!isEqual(nextProps.root.asset, this.props.root.asset)) { + + // TODO: remove asset data from redux store. + fetchAssetSuccess(nextProps.root.asset); + + const {setCommentCountCache, commentCountCache} = this.props; + const {asset} = nextProps.root; + + if (commentCountCache === -1) { + setCommentCountCache(asset.commentCount); + } + } + } + + componentDidUpdate(prevProps) { + if(!isEqual(prevProps.root.comment, this.props.root.comment)) { + + // Scroll to a permalinked comment if one is in the URL once the page is done rendering. + setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0); + } + } + + render() { + if (!this.props.root.asset) { + return ; + } + return ; + } +} + +const EMBED_QUERY = gql` + query EmbedQuery($assetId: ID, $assetUrl: String, $commentId: ID!, $hasComment: Boolean!, $excludeIgnored: Boolean) { + asset(id: $assetId, url: $assetUrl) { + totalCommentCount(excludeIgnored: $excludeIgnored) + } + me { + status + } + ...${getDefinitionName(Stream.fragments.root)} + } + ${Stream.fragments.root} +`; + +export const withQuery = graphql(EMBED_QUERY, { + options: ({auth, commentId, assetId, assetUrl}) => ({ + variables: { + assetId, + assetUrl, + commentId, + hasComment: commentId !== '', + excludeIgnored: Boolean(auth && auth.user && auth.user.id), + }, + reducer: (previousResult, action, variables) => { + return reduceEditCommentActionsToUpdateStreamQuery(previousResult, action, variables); + }, + }), + props: ({data}) => separateDataAndRoot(data), +}); + +const mapStateToProps = state => ({ + auth: state.auth.toJS(), + commentCountCache: state.stream.commentCountCache, + commentId: state.stream.commentId, + assetId: state.stream.assetId, + assetUrl: state.stream.assetUrl, + activeTab: state.embed.activeTab, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators({ + fetchAssetSuccess, + checkLogin, + setCommentCountCache, + viewAllComments, + logout, + setActiveTab, + }, dispatch); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + branch( + props => !props.auth.checkedInitialLogin, + renderComponent(Spinner), + ), + withQuery, +)(EmbedContainer); + +/** + * Reduce editComment mutation actions + * producing a new queryStream result where asset.comments reflects the edit + */ +function reduceEditCommentActionsToUpdateStreamQuery(previousResult, action) { + if ( ! (action.type === 'APOLLO_MUTATION_RESULT' && action.operationName === 'editComment')) { + return previousResult; + } + const resultHasErrors = (result) => { + try { + return result.data.editComment.errors.length > 0; + } catch (error) { + + // expected if no errors; + return false; + } + }; + if (resultHasErrors(action.result)) { + return previousResult; + } + const {variables: {id, edit}} = action; + const updateCommentWithEdit = (comment, edit) => { + const {body} = edit; + const editedComment = update(comment, { + $merge: { + body + }, + editing: {$merge:{edited:true}} + }); + return editedComment; + }; + const resultReflectingEdit = update(previousResult, { + asset: { + comments: { + $apply: comments => comments.map(comment => { + if (comment.id === id) { + return updateCommentWithEdit(comment, edit); + } + return update(comment, { + replies: { + $apply: (comments) => comments.map(comment => { + if (comment.id === id) { + return updateCommentWithEdit(comment, edit); + } + return comment; + }) + }, + }); + }) + } + } + }); + return resultReflectingEdit; +} + diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js new file mode 100644 index 000000000..443008913 --- /dev/null +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -0,0 +1,248 @@ +import React from 'react'; +import {gql, compose} from 'react-apollo'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import uniqBy from 'lodash/uniqBy'; +import sortBy from 'lodash/sortBy'; +import isNil from 'lodash/isNil'; +import {NEW_COMMENT_COUNT_POLL_INTERVAL} from '../constants/stream'; +import {postComment, postFlag, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser, editComment} from 'coral-framework/graphql/mutations'; +import {notificationActions, authActions} from 'coral-framework'; +import {editName} from 'coral-framework/actions/user'; +import {setCommentCountCache, setActiveReplyBox} from '../actions/stream'; +import Stream from '../components/Stream'; +import Comment from './Comment'; +import withFragments from 'coral-framework/hocs/withFragments'; +import {getDefinitionName} from 'coral-framework/utils'; + +const {showSignInDialog} = authActions; +const {addNotification} = notificationActions; + +class StreamContainer extends React.Component { + getCounts = (variables) => { + return this.props.data.fetchMore({ + query: LOAD_COMMENT_COUNTS_QUERY, + variables, + + // Apollo requires this, even though we don't use it... + updateQuery: data => data, + }); + }; + + // handle paginated requests for more Comments pertaining to the Asset + loadMore = ({limit, cursor, parent_id = null, asset_id, sort}, newComments) => { + return this.props.data.fetchMore({ + query: LOAD_MORE_QUERY, + variables: { + limit, // how many comments are we returning + cursor, // the date of the first/last comment depending on the sort order + parent_id, // if null, we're loading more top-level comments, if not, we're loading more replies to a comment + asset_id, // the id of the asset we're currently on + sort, // CHRONOLOGICAL or REVERSE_CHRONOLOGICAL + excludeIgnored: this.props.data.variables.excludeIgnored, + }, + updateQuery: (oldData, {fetchMoreResult:{new_top_level_comments}}) => { + let updatedAsset; + + if (!isNil(oldData.comment)) { // loaded replies on a highlighted (permalinked) comment + + let comment = {}; + if (oldData.comment && oldData.comment.parent) { + + // put comments (replies) onto the oldData.comment.parent object + // the initial comment permalinked was a reply + const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.parent.replies], 'id'); + comment.parent = {...oldData.comment.parent, replies: sortBy(uniqReplies, 'created_at')}; + } else if (oldData.comment) { + + // put the comments (replies) directly onto oldData.comment + // the initial comment permalinked was a top-level comment + const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.replies], 'id'); + comment.replies = sortBy(uniqReplies, 'created_at'); + } + + updatedAsset = { + ...oldData, + comment: { + ...oldData.comment, + ...comment + } + }; + + } else if (parent_id) { // If loading more replies + + updatedAsset = { + ...oldData, + asset: { + ...oldData.asset, + comments: oldData.asset.comments.map(comment => { + + // since the dipslayed replies and the returned replies can overlap, + // pull out the unique ones. + const uniqueReplies = uniqBy([...new_top_level_comments, ...comment.replies], 'id'); + + // since we just gave the returned replies precedence, they're now out of order. + // resort according to date. + return comment.id === parent_id + ? {...comment, replies: sortBy(uniqueReplies, 'created_at')} + : comment; + }) + } + }; + } else { // If loading more top-level comments + + updatedAsset = { + ...oldData, + asset: { + ...oldData.asset, + comments: newComments ? [...new_top_level_comments.reverse(), ...oldData.asset.comments] + : [...oldData.asset.comments, ...new_top_level_comments] + } + }; + } + + return updatedAsset; + } + }); + }; + + componentDidMount() { + if (this.props.previousTab) { + this.props.data.refetch(); + } + this.countPoll = setInterval(() => { + this.getCounts(this.props.data.variables); + }, NEW_COMMENT_COUNT_POLL_INTERVAL); + } + + componentWillUnmount() { + clearInterval(this.countPoll); + } + + render() { + return ; + } +} + +const LOAD_COMMENT_COUNTS_QUERY = gql` + query LoadCommentCounts($assetUrl: String, $assetId: ID, $excludeIgnored: Boolean) { + asset(id: $assetId, url: $assetUrl) { + id + commentCount(excludeIgnored: $excludeIgnored) + comments(limit: 10) { + id + replyCount(excludeIgnored: $excludeIgnored) + } + } + } +`; + +const LOAD_MORE_QUERY = gql` + query LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) { + new_top_level_comments: comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) { + ...${getDefinitionName(Comment.fragments.comment)} + replyCount(excludeIgnored: $excludeIgnored) + replies(limit: 3) { + ...${getDefinitionName(Comment.fragments.comment)} + } + } + } + ${Comment.fragments.comment} +`; + +const fragments = { + root: gql` + fragment Stream_root on RootQuery { + comment(id: $commentId) @include(if: $hasComment) { + ...${getDefinitionName(Comment.fragments.comment)} + replyCount(excludeIgnored: $excludeIgnored) + replies { + ...${getDefinitionName(Comment.fragments.comment)} + } + parent { + ...${getDefinitionName(Comment.fragments.comment)} + replyCount(excludeIgnored: $excludeIgnored) + replies { + ...${getDefinitionName(Comment.fragments.comment)} + } + } + } + asset(id: $assetId, url: $assetUrl) { + id + title + url + closedAt + created_at + settings { + moderation + infoBoxEnable + infoBoxContent + premodLinksEnable + questionBoxEnable + questionBoxContent + closeTimeout + closedMessage + charCountEnable + charCount + requireEmailConfirmation + } + lastComment { + id + } + commentCount(excludeIgnored: $excludeIgnored) + totalCommentCount(excludeIgnored: $excludeIgnored) + comments(limit: 10, excludeIgnored: $excludeIgnored) { + ...${getDefinitionName(Comment.fragments.comment)} + replyCount(excludeIgnored: $excludeIgnored) + replies(limit: 3, excludeIgnored: $excludeIgnored) { + ...${getDefinitionName(Comment.fragments.comment)} + } + } + } + myIgnoredUsers { + id, + username, + } + me { + status + } + ...${getDefinitionName(Comment.fragments.root)} + } + ${Comment.fragments.root} + ${Comment.fragments.comment} + `, +}; + +const mapStateToProps = state => ({ + auth: state.auth.toJS(), + commentCountCache: state.stream.commentCountCache, + activeReplyBox: state.stream.activeReplyBox, + commentId: state.stream.commentId, + assetId: state.stream.assetId, + assetUrl: state.stream.assetUrl, + activeTab: state.embed.activeTab, + previousTab: state.embed.previousTab, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators({ + showSignInDialog, + addNotification, + setActiveReplyBox, + editName, + setCommentCountCache, + }, dispatch); + +export default compose( + withFragments(fragments), + connect(mapStateToProps, mapDispatchToProps), + postComment, + postFlag, + postDontAgree, + addCommentTag, + removeCommentTag, + ignoreUser, + deleteAction, + editComment, +)(StreamContainer); + diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index d7ee99e73..2fd1e2731 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -3,12 +3,21 @@ import {render} from 'react-dom'; import {ApolloProvider} from 'react-apollo'; import {client} from 'coral-framework/services/client'; -import localStore from 'coral-framework/services/store'; +import {checkLogin} from 'coral-framework/actions/auth'; +import reducers from './reducers'; +import localStore, {injectReducers} from 'coral-framework/services/store'; import AppRouter from './AppRouter'; +injectReducers(reducers); + const store = (window.opener && window.opener.coralStore) ? window.opener.coralStore : localStore; +// Don't run this in the popup. +if (store === localStore) { + store.dispatch(checkLogin()); +} + render( diff --git a/client/coral-embed-stream/src/reducers/embed.js b/client/coral-embed-stream/src/reducers/embed.js new file mode 100644 index 000000000..0fd661543 --- /dev/null +++ b/client/coral-embed-stream/src/reducers/embed.js @@ -0,0 +1,19 @@ +import * as actions from '../constants/embed'; + +const initialState = { + activeTab: 'stream', + previousTab: '', +}; + +export default function stream(state = initialState, action) { + switch (action.type) { + case actions.SET_ACTIVE_TAB: + return { + ...state, + activeTab: action.tab, + previousTab: state.activeTab, + }; + default: + return state; + } +} diff --git a/client/coral-embed-stream/src/reducers/index.js b/client/coral-embed-stream/src/reducers/index.js new file mode 100644 index 000000000..a9049fc14 --- /dev/null +++ b/client/coral-embed-stream/src/reducers/index.js @@ -0,0 +1,7 @@ +import stream from './stream'; +import embed from './embed'; + +export default { + stream, + embed, +}; diff --git a/client/coral-embed-stream/src/reducers/stream.js b/client/coral-embed-stream/src/reducers/stream.js new file mode 100644 index 000000000..59f068530 --- /dev/null +++ b/client/coral-embed-stream/src/reducers/stream.js @@ -0,0 +1,45 @@ +import * as actions from '../constants/stream'; + +function getQueryVariable(variable) { + let query = window.location.search.substring(1); + let vars = query.split('&'); + for (let i = 0; i < vars.length; i++) { + let pair = vars[i].split('='); + if (decodeURIComponent(pair[0]) === variable) { + return decodeURIComponent(pair[1]); + } + } + + // If not found, return null. + return null; +} + +const initialState = { + activeReplyBox: '', + commentCountCache: -1, + assetId: getQueryVariable('asset_id'), + assetUrl: getQueryVariable('asset_url'), + commentId: getQueryVariable('comment_id'), +}; + +export default function stream(state = initialState, action) { + switch (action.type) { + case actions.SET_ACTIVE_REPLY_BOX: + return { + ...state, + activeReplyBox: action.id, + }; + case actions.SET_COMMENT_COUNT_CACHE: + return { + ...state, + commentCountCache: action.amount, + }; + case actions.VIEW_ALL_COMMENTS: + return { + ...state, + commentId: '', + }; + default: + return state; + } +} diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 5c47437ec..42a0d7c85 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -351,17 +351,6 @@ button.comment__action-button[disabled], /* Flag Styles */ -.coral-plugin-flags-container { - position: relative; -} - -.coral-plugin-flags-popup span { - min-width: 280px; - bottom: 36px; - position: absolute; - right: 10px; -} - .coral-plugin-flags-popup-form { margin-bottom: 10px; } @@ -398,6 +387,7 @@ button.comment__action-button[disabled], margin-top: 5px; width: 75%; font-size: 16px; + border: 1px solid #ccc; } /* Close comments */ diff --git a/client/coral-framework/actions/asset.js b/client/coral-framework/actions/asset.js index 50efa3dcb..6de4eaf9f 100644 --- a/client/coral-framework/actions/asset.js +++ b/client/coral-framework/actions/asset.js @@ -1,7 +1,6 @@ import * as actions from '../constants/asset'; import coralApi from '../helpers/response'; import {addNotification} from '../actions/notification'; -import {pym} from 'coral-framework'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from './../translations'; @@ -39,7 +38,6 @@ export const updateOpenStream = closedBody => (dispatch, getState) => { const openStream = () => ({type: actions.OPEN_COMMENTS}); const closeStream = () => ({type: actions.CLOSE_COMMENTS}); -export const updateCountCache = (id, count) => ({type: actions.UPDATE_COUNT_CACHE, id, count}); export const updateOpenStatus = status => dispatch => { if (status === 'open') { @@ -51,36 +49,3 @@ export const updateOpenStatus = status => dispatch => { } }; -function removeParam(key, sourceURL) { - let rtn = sourceURL.split('?')[0]; - let param; - let params_arr = []; - let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : ''; - if (queryString !== '') { - params_arr = queryString.split('&'); - for (let i = params_arr.length - 1; i >= 0; i -= 1) { - param = params_arr[i].split('=')[0]; - if (param === key) { - params_arr.splice(i, 1); - } - } - rtn = `${rtn}?${params_arr.join('&')}`; - } - return rtn; -} - -export const viewAllComments = () => { - - // remove the comment_id url param - const modifiedUrl = removeParam('comment_id', location.href); - try { - - // "window" here refers to the embedded iframe - window.history.replaceState({}, document.title, modifiedUrl); - - // also change the parent url - pym.sendMessage('coral-view-all-comments'); - } catch (e) { /* not sure if we're worried about old browsers */ } - - return {type: actions.VIEW_ALL_COMMENTS}; -}; diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index e112b2b6c..d689aeb63 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -39,10 +39,21 @@ export const showSignInDialog = () => dispatch => { 'menubar=0,resizable=0,width=500,height=550,top=200,left=500' ); - signInPopUp.onbeforeunload = () => { - dispatch(checkLogin()); - fetchMe(); + // Workaround odd behavior in older WebKit versions, where + // onunload is called twice. (Encountered in IOS 8.3) + let loaded = false; + signInPopUp.onload = () => { + loaded = true; }; + + // Use `onunload` instead of `onbeforeunload` which is not supported in IOS Safari. + signInPopUp.onunload = () => { + if (loaded) { + dispatch(checkLogin()); + fetchMe(); + } + }; + dispatch({type: actions.SHOW_SIGNIN_DIALOG}); }; export const hideSignInDialog = () => dispatch => { @@ -177,7 +188,13 @@ export const fetchSignUp = (formData, redirectUri) => (dispatch) => { dispatch(signUpSuccess(user)); }) .catch(error => { - dispatch(signUpFailure(lang.t(`error.${error.message}`))); + let errorMessage = lang.t(`error.${error.message}`); + + // if there is no translation defined, just show the error string + if (errorMessage === `error.${error.message}`) { + errorMessage = error.message; + } + dispatch(signUpFailure(errorMessage)); }); }; diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index c1e5dfe65..8e2dd9502 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -1,20 +1,16 @@ -import React, {Component} from 'react'; -import {getSlotElements} from 'coral-framework/helpers/plugins'; +import React from 'react'; +import cn from 'classnames'; import styles from './Slot.css'; +import {getSlotElements} from 'coral-framework/helpers/plugins'; -class Slot extends Component { - render() { - const {fill, inline = false, ...rest} = this.props; - return ( -
    - {getSlotElements(fill, rest)} -
    - ); - } +export default function Slot ({fill, inline = false, ...rest}) { + return ( +
    + {getSlotElements(fill, rest)} +
    + ); } Slot.propTypes = { fill: React.PropTypes.string }; - -export default Slot; diff --git a/client/coral-framework/constants/asset.js b/client/coral-framework/constants/asset.js index 5547c5284..c26dd099d 100644 --- a/client/coral-framework/constants/asset.js +++ b/client/coral-framework/constants/asset.js @@ -8,6 +8,4 @@ export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE'; export const OPEN_COMMENTS = 'OPEN_COMMENTS'; export const CLOSE_COMMENTS = 'CLOSE_COMMENTS'; -export const UPDATE_COUNT_CACHE = 'UPDATE_COUNT_CACHE'; -export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS'; diff --git a/client/coral-framework/constants/comments.js b/client/coral-framework/constants/comments.js deleted file mode 100644 index 28db9cdf9..000000000 --- a/client/coral-framework/constants/comments.js +++ /dev/null @@ -1,2 +0,0 @@ -export const ADDTL_COMMENTS_ON_LOAD_MORE = 10; -export const NEW_COMMENT_COUNT_POLL_INTERVAL = 20000; diff --git a/client/coral-framework/graphql/mutations/index.js b/client/coral-framework/graphql/mutations/index.js index 2d57c8aac..be0a0624a 100644 --- a/client/coral-framework/graphql/mutations/index.js +++ b/client/coral-framework/graphql/mutations/index.js @@ -1,7 +1,6 @@ import {graphql} from 'react-apollo'; import POST_COMMENT from './postComment.graphql'; import POST_FLAG from './postFlag.graphql'; -import POST_LIKE from './postLike.graphql'; import POST_DONT_AGREE from './postDontAgree.graphql'; import DELETE_ACTION from './deleteAction.graphql'; import ADD_COMMENT_TAG from './addCommentTag.graphql'; @@ -10,10 +9,6 @@ import IGNORE_USER from './ignoreUser.graphql'; import STOP_IGNORING_USER from './stopIgnoringUser.graphql'; import EDIT_COMMENT from './editComment.graphql'; -import MY_IGNORED_USERS from '../queries/myIgnoredUsers.graphql'; -import STREAM_QUERY from '../queries/streamQuery.graphql'; -import {variablesForStreamQuery} from '../queries'; - import commentView from '../fragments/commentView.graphql'; export const postComment = graphql(POST_COMMENT, { @@ -46,7 +41,7 @@ export const postComment = graphql(POST_COMMENT, { } }, updateQueries: { - AssetQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => { + EmbedQuery: (oldData, {mutationResult: {data: {createComment: {comment}}}}) => { if (oldData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') { return oldData; @@ -62,7 +57,7 @@ export const postComment = graphql(POST_COMMENT, { ...oldData.asset, comments: oldData.asset.comments.map((oldComment) => { return oldComment.id === parent_id - ? {...oldComment, replies: [...oldComment.replies, comment]} + ? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1} : oldComment; }) } @@ -88,17 +83,6 @@ export const postComment = graphql(POST_COMMENT, { }), }); -export const postLike = graphql(POST_LIKE, { - props: ({mutate}) => ({ - postLike: (like) => { - return mutate({ - variables: { - like - } - }); - }}), -}); - export const postFlag = graphql(POST_FLAG, { props: ({mutate}) => ({ postFlag: (flag) => { @@ -156,6 +140,7 @@ export const removeCommentTag = graphql(REMOVE_COMMENT_TAG, { }}), }); +// TODO: don't rely on refetching. export const ignoreUser = graphql(IGNORE_USER, { props: ({mutate}) => ({ ignoreUser: ({id}) => { @@ -163,15 +148,16 @@ export const ignoreUser = graphql(IGNORE_USER, { variables: { id, }, - refetchQueries: [{ - query: MY_IGNORED_USERS, - }] + refetchQueries: [ + 'EmbedQuery', 'myIgnoredUsers', + ] }); }}), }); +// TODO: don't rely on refetching. export const stopIgnoringUser = graphql(STOP_IGNORING_USER, { - props: ({mutate, ownProps}) => { + props: ({mutate}) => { return { stopIgnoringUser: ({id}) => { return mutate({ @@ -179,13 +165,7 @@ export const stopIgnoringUser = graphql(STOP_IGNORING_USER, { id, }, refetchQueries: [ - { - query: MY_IGNORED_USERS, - }, - { - query: STREAM_QUERY, - variables: variablesForStreamQuery(ownProps), - } + 'EmbedQuery', 'myIgnoredUsers', ] }); } @@ -194,7 +174,7 @@ export const stopIgnoringUser = graphql(STOP_IGNORING_USER, { }); export const editComment = graphql(EDIT_COMMENT, { - props: ({mutate, ownProps}) => { + props: ({mutate}) => { return { editComment: (id, edit) => { return mutate({ @@ -203,10 +183,7 @@ export const editComment = graphql(EDIT_COMMENT, { edit, }, refetchQueries: [ - { - query: STREAM_QUERY, - variables: variablesForStreamQuery(ownProps), - } + 'EmbedQuery' ] }); } diff --git a/client/coral-framework/graphql/mutations/postLike.graphql b/client/coral-framework/graphql/mutations/postLike.graphql deleted file mode 100644 index 350904352..000000000 --- a/client/coral-framework/graphql/mutations/postLike.graphql +++ /dev/null @@ -1,10 +0,0 @@ -mutation CreateLike ($like: CreateLikeInput!) { - createLike(like:$like) { - like { - id - } - errors { - translation_key - } - } -} diff --git a/client/coral-framework/graphql/queries/commentQuery.graphql b/client/coral-framework/graphql/queries/commentQuery.graphql deleted file mode 100644 index 83f92b8f5..000000000 --- a/client/coral-framework/graphql/queries/commentQuery.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#import "../fragments/commentView.graphql" - -query commentQuery($id: ID!) { - comment(id: $id) { - ...commentView - parent { - ...commentView - replies { - ...commentView - } - } - } -} diff --git a/client/coral-framework/graphql/queries/getCounts.graphql b/client/coral-framework/graphql/queries/getCounts.graphql deleted file mode 100644 index ff09a0498..000000000 --- a/client/coral-framework/graphql/queries/getCounts.graphql +++ /dev/null @@ -1,10 +0,0 @@ -query LoadCommentCounts($asset_id: ID, $limit: Int = 5, $sort: SORT_ORDER) { - asset(id: $asset_id) { - id - commentCount - comments(sort: $sort, limit: $limit) { - id - replyCount - } - } -} diff --git a/client/coral-framework/graphql/queries/index.js b/client/coral-framework/graphql/queries/index.js index 1cab53e2a..d76614a68 100644 --- a/client/coral-framework/graphql/queries/index.js +++ b/client/coral-framework/graphql/queries/index.js @@ -1,212 +1,6 @@ import {graphql} from 'react-apollo'; -import update from 'immutability-helper'; -import STREAM_QUERY from './streamQuery.graphql'; -import LOAD_MORE from './loadMore.graphql'; -import GET_COUNTS from './getCounts.graphql'; import MY_COMMENT_HISTORY from './myCommentHistory.graphql'; import MY_IGNORED_USERS from './myIgnoredUsers.graphql'; -import uniqBy from 'lodash/uniqBy'; -import sortBy from 'lodash/sortBy'; -import isNil from 'lodash/isNil'; - -function getQueryVariable(variable) { - let query = window.location.search.substring(1); - let vars = query.split('&'); - for (let i = 0; i < vars.length; i++) { - let pair = vars[i].split('='); - if (decodeURIComponent(pair[0]) === variable) { - return decodeURIComponent(pair[1]); - } - } - - // If not found, return null. - return null; -} - -// get the counts of the top-level comments -export const getCounts = (data) => ({asset_id, limit, sort}) => { - return data.fetchMore({ - query: GET_COUNTS, - variables: { - asset_id, - limit, - sort, - excludeIgnored: data.variables.excludeIgnored, - }, - updateQuery: (oldData, {fetchMoreResult:{asset}}) => { - return { - ...oldData, - asset: { - ...oldData.asset, - commentCount: asset.commentCount - } - }; - } - }); -}; - -// handle paginated requests for more Comments pertaining to the Asset -export const loadMore = (data) => ({limit, cursor, parent_id = null, asset_id, sort}, newComments) => { - return data.fetchMore({ - query: LOAD_MORE, - variables: { - limit, // how many comments are we returning - cursor, // the date of the first/last comment depending on the sort order - parent_id, // if null, we're loading more top-level comments, if not, we're loading more replies to a comment - asset_id, // the id of the asset we're currently on - sort, // CHRONOLOGICAL or REVERSE_CHRONOLOGICAL - excludeIgnored: data.variables.excludeIgnored, - }, - updateQuery: (oldData, {fetchMoreResult:{new_top_level_comments}}) => { - let updatedAsset; - - if (!isNil(oldData.comment)) { // loaded replies on a highlighted (permalinked) comment - - let comment = {}; - if (oldData.comment && oldData.comment.parent) { - - // put comments (replies) onto the oldData.comment.parent object - // the initial comment permalinked was a reply - const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.parent.replies], 'id'); - comment.parent = {...oldData.comment.parent, replies: sortBy(uniqReplies, 'created_at')}; - } else if (oldData.comment) { - - // put the comments (replies) directly onto oldData.comment - // the initial comment permalinked was a top-level comment - const uniqReplies = uniqBy([...new_top_level_comments, ...oldData.comment.replies], 'id'); - comment.replies = sortBy(uniqReplies, 'created_at'); - } - - updatedAsset = { - ...oldData, - comment: { - ...oldData.comment, - ...comment - } - }; - - } else if (parent_id) { // If loading more replies - - updatedAsset = { - ...oldData, - asset: { - ...oldData.asset, - comments: oldData.asset.comments.map(comment => { - - // since the dipslayed replies and the returned replies can overlap, - // pull out the unique ones. - const uniqueReplies = uniqBy([...new_top_level_comments, ...comment.replies], 'id'); - - // since we just gave the returned replies precedence, they're now out of order. - // resort according to date. - return comment.id === parent_id - ? {...comment, replies: sortBy(uniqueReplies, 'created_at')} - : comment; - }) - } - }; - } else { // If loading more top-level comments - - updatedAsset = { - ...oldData, - asset: { - ...oldData.asset, - comments: newComments ? [...new_top_level_comments.reverse(), ...oldData.asset.comments] - : [...oldData.asset.comments, ...new_top_level_comments] - } - }; - } - - return updatedAsset; - } - }); -}; - -export const variablesForStreamQuery = ({auth}) => { - - // where the query string is from the embeded iframe url - let comment_id = getQueryVariable('comment_id'); - let has_comment = comment_id != null; - return { - asset_id: getQueryVariable('asset_id'), - asset_url: getQueryVariable('asset_url'), - comment_id: has_comment ? comment_id : 'no-comment', - has_comment, - excludeIgnored: Boolean(auth && auth.user && auth.user.id), - }; -}; - -// load the comment stream. -export const queryStream = graphql(STREAM_QUERY, { - options: (props) => { - return { - variables: variablesForStreamQuery(props), - reducer: (previousResult, action, variables) => { - return reduceEditCommentActionsToUpdateStreamQuery(previousResult, action, variables); - }, - }; - }, - props: ({data}) => ({ - data, - loadMore: loadMore(data), - getCounts: getCounts(data), - }) -}); - -/** - * Reduce editComment mutation actions - * producing a new queryStream result where asset.comments reflects the edit - */ -function reduceEditCommentActionsToUpdateStreamQuery(previousResult, action) { - if ( ! (action.type === 'APOLLO_MUTATION_RESULT' && action.operationName === 'editComment')) { - return previousResult; - } - const resultHasErrors = (result) => { - try { - return result.data.editComment.errors.length > 0; - } catch (error) { - - // expected if no errors; - return false; - } - }; - if (resultHasErrors(action.result)) { - return previousResult; - } - const {variables: {id, edit}} = action; - const updateCommentWithEdit = (comment, edit) => { - const {body} = edit; - const editedComment = update(comment, { - $merge: { - body - }, - editing: {$merge:{edited:true}} - }); - return editedComment; - }; - const resultReflectingEdit = update(previousResult, { - asset: { - comments: { - $apply: comments => comments.map(comment => { - if (comment.id === id) { - return updateCommentWithEdit(comment, edit); - } - return update(comment, { - replies: { - $apply: (comments) => comments.map(comment => { - if (comment.id === id) { - return updateCommentWithEdit(comment, edit); - } - return comment; - }) - }, - }); - }) - } - } - }); - return resultReflectingEdit; -} export const myCommentHistory = graphql(MY_COMMENT_HISTORY, {}); diff --git a/client/coral-framework/graphql/queries/loadMore.graphql b/client/coral-framework/graphql/queries/loadMore.graphql deleted file mode 100644 index b18f4d84e..000000000 --- a/client/coral-framework/graphql/queries/loadMore.graphql +++ /dev/null @@ -1,11 +0,0 @@ -#import "../fragments/commentView.graphql" - -query LoadMoreComments($limit: Int = 5, $cursor: Date, $parent_id: ID, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) { - new_top_level_comments: comments(query: {limit: $limit, cursor: $cursor, parent_id: $parent_id, asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) { - ...commentView - replyCount(excludeIgnored: $excludeIgnored) - replies(limit: 3) { - ...commentView - } - } -} diff --git a/client/coral-framework/graphql/queries/streamQuery.graphql b/client/coral-framework/graphql/queries/streamQuery.graphql deleted file mode 100644 index c9a7690fc..000000000 --- a/client/coral-framework/graphql/queries/streamQuery.graphql +++ /dev/null @@ -1,53 +0,0 @@ -#import "../fragments/commentView.graphql" - -query AssetQuery($asset_id: ID, $asset_url: String, $comment_id: ID!, $has_comment: Boolean!, $excludeIgnored: Boolean) { - # the comment here is for loading one comment and it's children, probably after following a permalink - # $has_comment is derived from the comment_id query param in the iframe url, - # which is in turn pulled from the host page url - comment(id: $comment_id) @include(if: $has_comment) { - ...commentView - replyCount(excludeIgnored: $excludeIgnored) - replies { - ...commentView - } - parent { - ...commentView - replyCount(excludeIgnored: $excludeIgnored) - replies { - ...commentView - } - } - } - asset(id: $asset_id, url: $asset_url) { - id - title - url - closedAt - created_at - settings { - moderation - infoBoxEnable - infoBoxContent - premodLinksEnable - questionBoxEnable - questionBoxContent - closeTimeout - closedMessage - charCountEnable - charCount - requireEmailConfirmation - } - lastComment { - id - } - commentCount(excludeIgnored: $excludeIgnored) - totalCommentCount(excludeIgnored: $excludeIgnored) - comments(limit: 10, excludeIgnored: $excludeIgnored) { - ...commentView - replyCount(excludeIgnored: $excludeIgnored) - replies(limit: 3, excludeIgnored: $excludeIgnored) { - ...commentView - } - } - } -} diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index e0d345db4..20aee7abb 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -1,7 +1,11 @@ import React from 'react'; import merge from 'lodash/merge'; import flatten from 'lodash/flatten'; +import flattenDeep from 'lodash/flattenDeep'; +import uniq from 'lodash/uniq'; import plugins from 'pluginsConfig'; +import {gql} from 'react-apollo'; +import {getDefinitionName} from 'coral-framework/utils'; export const pluginReducers = merge( ...plugins @@ -19,3 +23,53 @@ export function getSlotElements(slot, props = {}) { return components .map((component, i) => React.createElement(component, {...props, key: i})); } + +function getComponentFragments(components) { + return components + .map(c => c.fragments) + .filter(fragments => fragments) + .reduce((res, fragments) => { + Object.keys(fragments).forEach(key => { + if (!(key in res)) { + res[key] = {spreads: '', definitions: ''}; + } + res[key].spreads += `...${getDefinitionName(fragments[key])}\n`; + res[key].definitions = gql`${res[key].definitions}${fragments[key]}`; + }); + return res; + }, {}); +} + +/** + * Returns an object that can be used to compose fragments or queries. + * + * Example: + * const pluginFragments = getSlotsFragments(['commentInfoBar', 'commentActions']); + * const rootFragment = gql` + * fragment Comment_root on RootQuery { + + ${pluginFragments.spreads('root')} + * } + * ${pluginFragments.definitions('root')} + * `; + */ +export function getSlotsFragments(slots) { + if (!Array.isArray(slots)) { + slots = [slots]; + } + const components = uniq(flattenDeep(slots.map(slot => { + return plugins + .filter(o => o.module.slots[slot]) + .map(o => o.module.slots[slot]); + }))); + + const fragments = getComponentFragments(components); + return { + spreads(key) { + return (fragments[key] && fragments[key].spreads) || ''; + }, + definitions(key) { + return (fragments[key] && fragments[key].definitions) || ''; + }, + }; +} + diff --git a/client/coral-framework/hocs/withFragments.js b/client/coral-framework/hocs/withFragments.js new file mode 100644 index 000000000..a2686d94b --- /dev/null +++ b/client/coral-framework/hocs/withFragments.js @@ -0,0 +1,18 @@ +import React from 'react'; + +// TODO: revisit `filtering` after https://github.com/apollographql/graphql-anywhere/issues/38. + +function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +} + +export default fragments => WrappedComponent => { + class WithFragments extends React.Component { + render() { + return ; + } + } + WithFragments.fragments = fragments; + WithFragments.displayName = `WithFragments(${getDisplayName(WrappedComponent)})`; + return WithFragments; +}; diff --git a/client/coral-framework/reducers/asset.js b/client/coral-framework/reducers/asset.js index 067edfc5b..f9d0a55e3 100644 --- a/client/coral-framework/reducers/asset.js +++ b/client/coral-framework/reducers/asset.js @@ -19,9 +19,6 @@ export default function asset (state = initialState, action) { case actions.UPDATE_ASSET_SETTINGS_SUCCESS: return state .setIn(['settings'], action.settings); - case actions.UPDATE_COUNT_CACHE: - return state - .setIn(['countCache', action.id], action.count); default: return state; } diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index 6e678cab9..83ea49ce5 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -8,6 +8,7 @@ const initialState = Map({ user: null, showSignInDialog: false, showCreateUsernameDialog: false, + checkedInitialLogin: false, view: 'SIGNIN', error: '', passwordRequestSuccess: null, @@ -71,10 +72,12 @@ export default function auth (state = initialState, action) { .set('isLoading', true); case actions.CHECK_LOGIN_FAILURE: return state + .set('checkedInitialLogin', true) .set('loggedIn', false) .set('user', null); case actions.CHECK_LOGIN_SUCCESS: return state + .set('checkedInitialLogin', true) .set('loggedIn', true) .set('isAdmin', action.isAdmin) .set('user', purge(action.user)); @@ -114,7 +117,11 @@ export default function auth (state = initialState, action) { .set('isLoading', false) .set('successSignUp', true); case actions.LOGOUT_SUCCESS: - return initialState; + return state + .set('user', null) + .set('isLoading', false) + .set('loggedIn', false) + .set('isAdmin', false); case actions.INVALID_FORM: return state .set('error', action.error); diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js index 949d37fee..07bd13ca1 100644 --- a/client/coral-framework/services/client.js +++ b/client/coral-framework/services/client.js @@ -1,6 +1,18 @@ import ApolloClient, {addTypename} from 'apollo-client'; import getNetworkInterface from './transport'; +// import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws'; + +// TODO: replace absolute reference with something loaded from the store/page. +// const wsClient = new SubscriptionClient('ws://localhost:3000/api/v1/live', { +// reconnect: true +// }); +// const networkInterface = addGraphQLSubscriptions( +// getNetworkInterface(), +// wsClient, +// ); +const networkInterface = getNetworkInterface(); + export const client = new ApolloClient({ connectToDevTools: true, queryTransformer: addTypename, @@ -10,7 +22,7 @@ export const client = new ApolloClient({ } return null; }, - networkInterface: getNetworkInterface() + networkInterface }); export default client; diff --git a/client/coral-framework/services/store.js b/client/coral-framework/services/store.js index d7090f4b5..f6ace1fd3 100644 --- a/client/coral-framework/services/store.js +++ b/client/coral-framework/services/store.js @@ -24,14 +24,22 @@ if (window.devToolsExtension) { middlewares.push(window.devToolsExtension()); } -const store = createStore( - combineReducers({ - ...mainReducer, - apollo: client.reducer() - }), +let storeReducers = { + ...mainReducer, + apollo: client.reducer() +}; + +export const store = createStore( + combineReducers(storeReducers), {}, compose(...middlewares) ); export default store; + +export function injectReducers(reducers) { + storeReducers = {...storeReducers, ...reducers}; + store.replaceReducer(combineReducers(storeReducers)); +} + window.coralStore = store; diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 4e9c29db7..598fb59ab 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -29,3 +29,35 @@ export const getMyActionSummary = (type, comment) => { export const getActionSummary = (type, comment) => { return comment.action_summaries.filter(a => a.__typename === type); }; + +/** + * Get name of first (or $pos-th) definition + */ +export function getDefinitionName(doc, pos = 0) { + return doc.definitions[pos].name.value; +} + +/** + * Separate apollo `data` props into `data` and `root`. + * `data` will contain props like `loading`, `fetchMore`... + * while `root` contains the actual query data. + */ +export function separateDataAndRoot( + { + fetchMore, + loading, + networkStatus, + refetch, + startPolling, + stopPolling, + subscribeToMore, + updateQuery, + variables, + ...root, + }) { + return { + data: {fetchMore, loading, networkStatus, refetch, startPolling, + stopPolling, subscribeToMore, updateQuery, variables}, + root, + }; +} diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index 5b60fac2e..cd38aba1a 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -1,7 +1,7 @@ -import React, {Component, PropTypes} from 'react'; +import React, {PropTypes} from 'react'; +import {Button} from 'coral-ui'; import {I18n} from '../coral-framework'; import translations from './translations.json'; -import {Button} from 'coral-ui'; import Slot from 'coral-framework/components/Slot'; import {connect} from 'react-redux'; import classnames from 'classnames'; @@ -21,7 +21,7 @@ export const notifyForNewCommentStatus = (addNotification, status) => { /** * Common UI for Creating or Editing a Comment */ -export class CommentForm extends Component { +export class CommentForm extends React.Component { static propTypes = { // Initial value for underlying comment body textarea @@ -113,6 +113,7 @@ export class CommentForm extends Component { id={this.props.bodyInputId} onChange={this.onBodyChange} rows={3}/> +
    { this.props.charCountEnable && @@ -147,8 +148,7 @@ export class CommentForm extends Component { /** * Container for posting a new Comment */ -class CommentBox extends Component { - +class CommentBox extends React.Component { constructor(props) { super(props); @@ -165,19 +165,19 @@ class CommentBox extends Component { } static get defaultProps() { return { - updateCountCache: () => {} + setCommentCountCache: () => {} }; } postComment = ({body}) => { const { + commentPostedHandler, + postItem, + setCommentCountCache, + commentCountCache, isReply, assetId, parentId, - postItem, - countCache, addNotification, - updateCountCache, - commentPostedHandler } = this.props; let comment = { @@ -187,7 +187,7 @@ class CommentBox extends Component { ...this.props.commentBox }; - !isReply && updateCountCache(assetId, countCache + 1); + !isReply && setCommentCountCache(commentCountCache + 1); // Execute preSubmit Hooks this.state.hooks.preSubmit.forEach(hook => hook()); @@ -202,16 +202,19 @@ class CommentBox extends Component { notifyForNewCommentStatus(addNotification, postedComment.status); if (postedComment.status === 'REJECTED') { - !isReply && updateCountCache(assetId, countCache); + !isReply && setCommentCountCache(assetId, commentCountCache); } else if (postedComment.status === 'PREMOD') { - !isReply && updateCountCache(assetId, countCache); + !isReply && setCommentCountCache(assetId, commentCountCache); } if (commentPostedHandler) { commentPostedHandler(); } }) - .catch((err) => console.error(err)); + .catch((err) => { + console.error(err); + !isReply && setCommentCountCache(commentCountCache); + }); this.setState({postedCount: this.state.postedCount + 1}); } @@ -288,7 +291,7 @@ class CommentBox extends Component { bodyInputId={isReply ? 'replyText' : 'commentText'} saveComment={authorId && this.postComment} buttonContainerStart={ ({commentBox}); diff --git a/client/coral-plugin-commentbox/translations.json b/client/coral-plugin-commentbox/translations.json index 8ad8b1813..4110df75d 100644 --- a/client/coral-plugin-commentbox/translations.json +++ b/client/coral-plugin-commentbox/translations.json @@ -3,7 +3,7 @@ "post": "Post", "cancel": "Cancel", "reply": "Reply", - "comment": "Post a Comment", + "comment": "Post a comment", "name": "Name", "comment-post-notif": "Your comment has been posted.", "comment-post-notif-premod": "Thank you for posting. Our moderation team will review your comment shortly.", @@ -14,7 +14,7 @@ "post": "Publicar", "cancel": "Cancelar", "reply": "Responder", - "comment": "Publicar un Comentario", + "comment": "Publicar un comentario", "name": "Nombre", "comment-post-notif": "Tu comentario ha sido publicado.", "comment-post-notif-premod": "Gracias por el comentario. Nuestro equipo de moderación va a revisarlo muy pronto.", diff --git a/client/coral-plugin-flags/FlagButton.js b/client/coral-plugin-flags/FlagButton.js index 16e616379..e5daf72c9 100644 --- a/client/coral-plugin-flags/FlagButton.js +++ b/client/coral-plugin-flags/FlagButton.js @@ -19,6 +19,12 @@ class FlagButton extends Component { localDelete: false } + componentDidUpdate () { + if (this.popup) { // this will be defined when the reporting popup is opened + this.popup.firstChild.style.top = `${this.flagButton.offsetTop - this.popup.firstChild.clientHeight - 15}px`; + } + } + // When the "report" button is clicked expand the menu onReportClick = () => { const {currentUser, deleteAction, flaggedByCurrentUser, flag} = this.props; @@ -135,7 +141,10 @@ class FlagButton extends Component { const popupMenu = getPopupMenu[this.state.step](this.state.itemType); return
    - { this.state.showMenu && -
    +
    this.popup = ref}>
    {popupMenu.header}
    { diff --git a/client/coral-plugin-likes/LikeButton.js b/client/coral-plugin-likes/LikeButton.js deleted file mode 100644 index 28382dbc2..000000000 --- a/client/coral-plugin-likes/LikeButton.js +++ /dev/null @@ -1,71 +0,0 @@ -import React, {Component, PropTypes} from 'react'; -import {I18n} from '../coral-framework'; -import translations from './translations.json'; - -const name = 'coral-plugin-likes'; - -class LikeButton extends Component { - - static propTypes = { - like: PropTypes.shape({ - current: PropTypes.object, - count: PropTypes.number - }), - id: PropTypes.string, - postLike: PropTypes.func.isRequired, - deleteAction: PropTypes.func.isRequired, - showSignInDialog: PropTypes.func.isRequired, - currentUser: PropTypes.shape({ - banned: PropTypes.boolean - }), - } - - state = { - localPost: null, // Set to the ID of an action if one is posted - localDelete: false // Set to true is the user deletes an action, unless localPost is already set. - } - - render() { - const {like, id, postLike, deleteAction, showSignInDialog, currentUser} = this.props; - let {totalLikes: count} = this.props; - const {localPost, localDelete} = this.state; - const liked = (like && like.current_user && !localDelete) || localPost; - if (localPost) {count += 1;} - if (localDelete) {count -= 1;} - - const onLikeClick = () => { - if (!currentUser) { - showSignInDialog(); - return; - } - if (currentUser.banned) { - return; - } - if (!liked) { // this comment has not yet been liked by this user. - this.setState({localPost: 'temp'}); - postLike({ - item_id: id, - item_type: 'COMMENTS' - }).then(({data}) => { - this.setState({localPost: data.createLike.like.id}); - }); - } else { - this.setState((prev) => prev.localPost ? {...prev, localPost: null} : {...prev, localDelete: true}); - deleteAction(localPost || like.current_user.id); - } - }; - - return
    - -
    ; - } -} - -export default LikeButton; - -const lang = new I18n(translations); diff --git a/client/coral-plugin-questionbox/QuestionBox.js b/client/coral-plugin-questionbox/QuestionBox.js index 31ad45869..3ad439c2a 100644 --- a/client/coral-plugin-questionbox/QuestionBox.js +++ b/client/coral-plugin-questionbox/QuestionBox.js @@ -1,5 +1,6 @@ import React from 'react'; const packagename = 'coral-plugin-questionbox'; +import Slot from 'coral-framework/components/Slot'; const QuestionBox = ({enable, content}) =>
    @@ -10,6 +11,7 @@ const QuestionBox = ({enable, content}) =>
    {content}
    +
    ; export default QuestionBox; diff --git a/client/coral-settings/containers/ProfileContainer.js b/client/coral-settings/containers/ProfileContainer.js index 553504462..39e615725 100644 --- a/client/coral-settings/containers/ProfileContainer.js +++ b/client/coral-settings/containers/ProfileContainer.js @@ -12,7 +12,6 @@ import NotLoggedIn from '../components/NotLoggedIn'; import IgnoredUsers from '../components/IgnoredUsers'; import {Spinner} from 'coral-ui'; import CommentHistory from 'coral-plugin-history/CommentHistory'; - import {showSignInDialog, checkLogin} from 'coral-framework/actions/auth'; import translations from '../translations'; @@ -35,14 +34,14 @@ class ProfileContainer extends Component { } render() { - const {loggedIn, asset, data, showSignInDialog, myIgnoredUsersData, stopIgnoringUser} = this.props; + const {asset, data, showSignInDialog, myIgnoredUsersData, stopIgnoringUser} = this.props; const {me} = this.props.data; if (data.loading) { return ; } - if (!loggedIn || !me) { + if (!me) { return ; } @@ -51,7 +50,7 @@ class ProfileContainer extends Component { return (
    -

    {this.props.userData.username}

    +

    {this.props.user.username}

    { emailAddress ?

    { emailAddress }

    : null diff --git a/client/coral-sign-in/components/FakeComment.js b/client/coral-sign-in/components/FakeComment.js index f5926852c..37630383d 100644 --- a/client/coral-sign-in/components/FakeComment.js +++ b/client/coral-sign-in/components/FakeComment.js @@ -1,5 +1,5 @@ import React from 'react'; -import styles from 'coral-embed-stream/src/Comment.css'; +import styles from 'coral-embed-stream/src/components/Comment.css'; import AuthorName from 'coral-plugin-author-name/AuthorName'; import Content from 'coral-plugin-commentcontent/CommentContent'; diff --git a/client/coral-sign-in/components/UserBox.js b/client/coral-sign-in/components/UserBox.js index 8e718614b..a44c3d4fb 100644 --- a/client/coral-sign-in/components/UserBox.js +++ b/client/coral-sign-in/components/UserBox.js @@ -4,11 +4,11 @@ import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations'; const lang = new I18n(translations); -const UserBox = ({className, user, logout, changeTab}) => ( +const UserBox = ({className, user, onLogout, onShowProfile}) => (
    {lang.t('signIn.loggedInAs')} - changeTab(1)}>{user.username}. {lang.t('signIn.notYou')} - {lang.t('signIn.logout')} + {user.username}. {lang.t('signIn.notYou')} + {lang.t('signIn.logout')}
    ); diff --git a/client/coral-ui/components/PopupMenu.css b/client/coral-ui/components/PopupMenu.css index fe57ba8b2..35544fbad 100644 --- a/client/coral-ui/components/PopupMenu.css +++ b/client/coral-ui/components/PopupMenu.css @@ -1,13 +1,17 @@ .popupMenu { - display: inline-block; - width: inherit; + white-space: normal; + display: block; + position: absolute; + max-width: 98%; + min-width: 50%; border: solid 1px #999; box-shadow: 3px 3px 5px 0 rgba(0, 0, 0, 0.3); box-sizing: border-box; background: white; border-radius: 3px; padding: 20px 10px; - z-index: 3; + z-index: 300; + right: 1%; } .popupMenu:before{ diff --git a/client/coral-ui/components/PopupMenu.js b/client/coral-ui/components/PopupMenu.js index dfc81c3a1..6d4381ffe 100644 --- a/client/coral-ui/components/PopupMenu.js +++ b/client/coral-ui/components/PopupMenu.js @@ -2,5 +2,5 @@ import React from 'react'; import styles from './PopupMenu.css'; export default ({children}) => ( - {children} +
    {children}
    ); diff --git a/errors.js b/errors.js index 6cd8d8da0..67ae0693a 100644 --- a/errors.js +++ b/errors.js @@ -106,7 +106,7 @@ class ErrAuthentication extends APIError { // ErrContainsProfanity is returned in the event that the middleware detects // profanity/wordlisted words in the payload. -const ErrContainsProfanity = new APIError('Suspected profanity. If you think this in error, please let us know!', { +const ErrContainsProfanity = new APIError('This username contains elements which are not permitted in our community. If you think this is in error, please contact us or try again.', { translation_key: 'PROFANITY_ERROR', status: 400 }); diff --git a/graph/context.js b/graph/context.js index f3ed42ad8..66086dc89 100644 --- a/graph/context.js +++ b/graph/context.js @@ -1,5 +1,6 @@ const loaders = require('./loaders'); const mutators = require('./mutators'); +const uuid = require('uuid'); const plugins = require('../services/plugins'); const debug = require('debug')('talk:graph:context'); @@ -31,7 +32,10 @@ const decorateContextPlugins = (context, contextPlugins) => contextPlugins.reduc * Stores the request context. */ class Context { - constructor({user = null}) { + constructor({user = null}, pubsub) { + + // Generate a new context id for the request. + this.id = uuid.v4(); // Load the current logged in user to `user`, otherwise this'll be null. if (user) { @@ -46,6 +50,9 @@ class Context { // Decorate the plugin context. this.plugins = decorateContextPlugins(this, contextPlugins); + + // Bind the publish/subscribe to the context. + this.pubsub = pubsub; } } diff --git a/graph/index.js b/graph/index.js index 7fddce2eb..4e99714d9 100644 --- a/graph/index.js +++ b/graph/index.js @@ -1,5 +1,7 @@ const schema = require('./schema'); const Context = require('./context'); +const pubsub = require('./pubsub'); +const {createSubscriptionManager} = require('./subscriptions'); module.exports = { createGraphOptions: (req) => ({ @@ -9,6 +11,7 @@ module.exports = { // Load in the new context here, this'll create the loaders + mutators for // the lifespan of this request. - context: new Context(req) - }) + context: new Context(req, pubsub) + }), + createSubscriptionManager }; diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index c813ac112..283d66738 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -16,7 +16,7 @@ const Wordlist = require('../../services/wordlist'); * @param {String} [status='NONE'] the status of the new comment * @return {Promise} resolves to the created comment */ -const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null, tags = []}, status = 'NONE') => { +const createComment = ({user, loaders: {Comments}, pubsub}, {body, asset_id, parent_id = null, tags = []}, status = 'NONE') => { // Building array of tags tags = tags.map(tag => ({name: tag})); @@ -47,6 +47,12 @@ const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = Comments.parentCountByAssetID.incr(asset_id); } Comments.countByAssetID.incr(asset_id); + + if (pubsub) { + + // Publish the newly added comment via the subscription. + pubsub.publish('commentAdded', comment); + } } return comment; diff --git a/graph/pubsub.js b/graph/pubsub.js new file mode 100644 index 000000000..a5ee95b80 --- /dev/null +++ b/graph/pubsub.js @@ -0,0 +1,5 @@ +const {RedisPubSub} = require('graphql-redis-subscriptions'); + +const {connectionOptions} = require('../services/redis'); + +module.exports = new RedisPubSub(connectionOptions); diff --git a/graph/resolvers/action.js b/graph/resolvers/action.js index 1bb8d077e..393024fe1 100644 --- a/graph/resolvers/action.js +++ b/graph/resolvers/action.js @@ -5,8 +5,6 @@ const Action = { return 'DontAgreeAction'; case 'FLAG': return 'FlagAction'; - case 'LIKE': - return 'LikeAction'; } }, diff --git a/graph/resolvers/action_summary.js b/graph/resolvers/action_summary.js index ac2154de8..f9c2ad324 100644 --- a/graph/resolvers/action_summary.js +++ b/graph/resolvers/action_summary.js @@ -3,8 +3,6 @@ const ActionSummary = { switch (action_type) { case 'FLAG': return 'FlagActionSummary'; - case 'LIKE': - return 'LikeActionSummary'; case 'DONTAGREE': return 'DontAgreeActionSummary'; } diff --git a/graph/resolvers/date.js b/graph/resolvers/date.js index 2335b9713..af79e3eb4 100644 --- a/graph/resolvers/date.js +++ b/graph/resolvers/date.js @@ -5,6 +5,10 @@ module.exports = new GraphQLScalarType({ name: 'Date', description: 'Date represented as an ISO8601 string', serialize(value) { + if (typeof value === 'string') { + return value; + } + return value.toISOString(); }, parseValue(value) { diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js index 05d633547..cbeb6dbf9 100644 --- a/graph/resolvers/index.js +++ b/graph/resolvers/index.js @@ -12,10 +12,10 @@ const FlagAction = require('./flag_action'); const DontAgreeAction = require('./dont_agree_action'); const DontAgreeActionSummary = require('./dont_agree_action_summary'); const GenericUserError = require('./generic_user_error'); -const LikeAction = require('./like_action'); const RootMutation = require('./root_mutation'); const RootQuery = require('./root_query'); const Settings = require('./settings'); +const Subscription = require('./subscription'); const UserError = require('./user_error'); const User = require('./user'); const ValidationUserError = require('./validation_user_error'); @@ -35,10 +35,10 @@ let resolvers = { DontAgreeAction, DontAgreeActionSummary, GenericUserError, - LikeAction, RootMutation, RootQuery, Settings, + Subscription, UserError, User, ValidationUserError, diff --git a/graph/resolvers/like_action.js b/graph/resolvers/like_action.js deleted file mode 100644 index 12d10f81e..000000000 --- a/graph/resolvers/like_action.js +++ /dev/null @@ -1,5 +0,0 @@ -const LikeAction = { - -}; - -module.exports = LikeAction; diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index 6305ee140..d3cee718c 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -8,9 +8,6 @@ const RootMutation = { editComment(_, args, {mutators: {Comment}}) { return wrapResponse('comment')(Comment.editComment(args)); }, - createLike(_, {like: {item_id, item_type}}, {mutators: {Action}}) { - return wrapResponse('like')(Action.create({item_id, item_type, action_type: 'LIKE'})); - }, createFlag(_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) { return wrapResponse('flag')(Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}})); }, diff --git a/graph/resolvers/subscription.js b/graph/resolvers/subscription.js new file mode 100644 index 000000000..b3f5e655c --- /dev/null +++ b/graph/resolvers/subscription.js @@ -0,0 +1,7 @@ +const Subscription = { + commentAdded(comment) { + return comment; + } +}; + +module.exports = Subscription; diff --git a/graph/subscriptions.js b/graph/subscriptions.js new file mode 100644 index 000000000..369b5c058 --- /dev/null +++ b/graph/subscriptions.js @@ -0,0 +1,60 @@ +const {SubscriptionManager} = require('graphql-subscriptions'); +const {SubscriptionServer} = require('subscriptions-transport-ws'); +const _ = require('lodash'); + +const pubsub = require('./pubsub'); +const schema = require('./schema'); +const Context = require('./context'); +const plugins = require('../services/plugins'); + +const {deserializeUser} = require('../services/subscriptions'); + +// Core setup functions +let setupFunctions = { + commentAdded: (options, args) => ({ + commentAdded: { + filter: (comment) => comment.asset_id === args.asset_id + }, + }), +}; + +/** + * Plugin support requires that we merge in existing setupFunctions with our new + * plugin based ones. This allows plugins to extend existing setupFunctions as well + * as provide new ones. + */ +setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {setupFunctions}) => { + + return _.merge(acc, setupFunctions); +}, setupFunctions); + +/** + * This creates a new subscription manager. + */ +const createSubscriptionManager = (server) => new SubscriptionServer({ + subscriptionManager: new SubscriptionManager({ + schema, + pubsub, + setupFunctions, + }), + onSubscribe: (parsedMessage, baseParams, connection) => { + + // Attach the context per request. + baseParams.context = () => deserializeUser(connection.upgradeReq) + .then((req) => new Context(req, pubsub)) + .catch((err) => { + console.error(err); + + return new Context({}, pubsub); + }); + + return baseParams; + } +}, { + server, + path: '/api/v1/live' +}); + +module.exports = { + createSubscriptionManager +}; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index c7ce02ff1..a16f4bc1c 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -103,9 +103,6 @@ enum COMMENT_STATUS { # The types of action there are as enum's. enum ACTION_TYPE { - # Represents a LikeAction. - LIKE - # Represents a FlagAction. FLAG @@ -303,41 +300,6 @@ type FlagAssetActionSummary implements AssetActionSummary { actionableItemCount: Int } -# A summary of counts related to all the Likes on an Asset. -type LikeAssetActionSummary implements AssetActionSummary { - - # Number of likes associated with actionable types on this this Asset. - actionCount: Int - - # Number of unique actionable types that are referenced by the likes. - actionableItemCount: Int -} - -# LikeAction is used by users who "like" a specific entity. -type LikeAction implements Action { - - # The ID of the action. - id: ID! - - # The author of the action. - user: User - - # The time when the Action was updated. - updated_at: Date - - # The time when the Action was created. - created_at: Date -} - -# LikeActionSummary is counts the amount of "likes" that a specific entity has. -type LikeActionSummary implements ActionSummary { - - # The count of likes against the parent entity. - count: Int! - - current_user: LikeAction -} - # A FLAG action that contains flag metadata. type FlagAction implements Action { @@ -549,9 +511,6 @@ enum USER_STATUS { # Metrics for the assets. enum ASSET_METRICS_SORT { - # Represents a LikeAction. - LIKE - # Represents a FlagAction. FLAG @@ -637,15 +596,6 @@ enum ACTION_ITEM_TYPE { USERS } -input CreateLikeInput { - - # The item's id for which we are to create a like. - item_id: ID! - - # The type of the item for which we are to create the like. - item_type: ACTION_ITEM_TYPE! -} - enum TAG_TYPE { STAFF } @@ -666,15 +616,6 @@ input CreateCommentInput { } -type CreateLikeResponse implements Response { - - # The like that was created. - like: LikeAction - - # An array of errors relating to the mutation that occurred. - errors: [UserError] -} - input CreateFlagInput { # The item's id for which we are to create a flag. @@ -811,9 +752,6 @@ type RootMutation { # Creates a comment on the asset. createComment(comment: CreateCommentInput!): CreateCommentResponse - # Creates a like on an entity. - createLike(like: CreateLikeInput!): CreateLikeResponse - # Creates a flag on an entity. createFlag(flag: CreateFlagInput!): CreateFlagResponse @@ -848,6 +786,14 @@ type RootMutation { stopIgnoringUser(id: ID!): StopIgnoringUserResponse } +################################################################################ +## Subscriptions +################################################################################ + +type Subscription { + commentAdded(asset_id: ID!): Comment +} + ################################################################################ ## Schema ################################################################################ @@ -855,4 +801,5 @@ type RootMutation { schema { query: RootQuery mutation: RootMutation + subscription: Subscription } diff --git a/models/user.js b/models/user.js index 2663410a7..dd30e8033 100644 --- a/models/user.js +++ b/models/user.js @@ -123,7 +123,13 @@ const UserSchema = new mongoose.Schema({ // user id of another user type: String, - }] + }], + + // Additional metadata stored on the field. + metadata: { + default: {}, + type: Object + } }, { // This will ensure that we have proper timestamps available on this model. diff --git a/out.gif b/out.gif deleted file mode 100644 index e69de29bb..000000000 diff --git a/package.json b/package.json index 9db7fa033..6476223b3 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "talk", - "version": "1.5.0", + "version": "1.6.0", "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", "scripts": { "postinstall": "./bin/cli plugins reconcile --skip-remote", - "start": "./bin/cli serve --jobs", - "dev-start": "nodemon --config .nodemon.json --exec \"./bin/cli -c .env serve --jobs\"", + "start": "./bin/cli serve -j -w", + "dev-start": "nodemon -w . -w bin/cli -w bin/cli-serve --config .nodemon.json --exec \"./bin/cli -c .env serve -j -w\"", "build": "NODE_ENV=production webpack -p --config webpack.config.js --bail", "build-watch": "NODE_ENV=development webpack --progress --config webpack.config.js --watch", "lint": "eslint bin/* .", @@ -68,10 +68,12 @@ "express-session": "^1.15.1", "form-data": "^2.1.2", "gql-merge": "^0.0.4", - "graphql": "^0.8.2", + "graphql": "^0.9.1", "graphql-errors": "^2.1.0", - "graphql-server-express": "^0.5.0", - "graphql-tools": "^0.9.0", + "graphql-redis-subscriptions": "^1.1.5", + "graphql-server-express": "^0.6.0", + "graphql-subscriptions": "^0.3.1", + "graphql-tools": "^0.10.1", "helmet": "^3.5.0", "immutability-helper": "^2.2.0", "inquirer": "^3.0.6", @@ -85,24 +87,29 @@ "minimist": "^1.2.0", "mongoose": "^4.9.1", "morgan": "^1.8.1", - "natural": "^0.4.0", + "natural": "^0.5.0", "node-emoji": "^1.5.1", "node-fetch": "^1.6.3", "nodemailer": "^2.6.4", "parse-duration": "^0.1.1", "passport": "^0.3.2", "passport-local": "^1.0.0", - "react-apollo": "^1.0.0", + "prop-types": "^15.5.8", + "react-apollo": "^1.1.0", "react-recaptcha": "^2.2.6", + "recompose": "^0.23.1", "redis": "^2.7.1", + "uuid": "^3.0.1", + "simplemde": "^1.11.2", + "subscriptions-transport-ws": "^0.5.5-alpha.0", "resolve": "^1.3.2", "semver": "^5.3.0", "simplemde": "^1.11.2", "timekeeper": "^1.0.0", - "uuid": "^2.0.3" + "uuid": "^3.0.1" }, "devDependencies": { - "apollo-client": "^1.0.0", + "apollo-client": "^1.0.4", "autoprefixer": "^6.5.2", "babel-cli": "^6.24.0", "babel-core": "^6.24.0", @@ -179,6 +186,7 @@ "regenerator": "^0.8.46", "selenium-standalone": "^5.11.2", "style-loader": "^0.16.0", + "subscriptions-transport-ws": "^0.5.5-alpha.0", "supertest": "^2.0.1", "timeago.js": "^2.0.3", "webpack": "^2.3.1" diff --git a/plugins/coral-plugin-like/client/.babelrc b/plugins/coral-plugin-like/client/.babelrc new file mode 100644 index 000000000..60be246eb --- /dev/null +++ b/plugins/coral-plugin-like/client/.babelrc @@ -0,0 +1,14 @@ +{ + "presets": [ + "es2015" + ], + "plugins": [ + "add-module-exports", + "transform-class-properties", + "transform-decorators-legacy", + "transform-object-assign", + "transform-object-rest-spread", + "transform-async-to-generator", + "transform-react-jsx" + ] +} \ No newline at end of file diff --git a/plugins/coral-plugin-like/client/.eslintrc.json b/plugins/coral-plugin-like/client/.eslintrc.json new file mode 100644 index 000000000..9fe56bd14 --- /dev/null +++ b/plugins/coral-plugin-like/client/.eslintrc.json @@ -0,0 +1,23 @@ +{ + "env": { + "browser": true, + "es6": true, + "mocha": true + }, + "parserOptions": { + "sourceType": "module", + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "jsx": true + } + }, + "parser": "babel-eslint", + "plugins": [ + "react" + ], + "rules": { + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "no-console": ["warn", { "allow": ["warn", "error"] }] + } +} diff --git a/plugins/coral-plugin-like/client/components/Icon.js b/plugins/coral-plugin-like/client/components/Icon.js new file mode 100644 index 000000000..c24841e97 --- /dev/null +++ b/plugins/coral-plugin-like/client/components/Icon.js @@ -0,0 +1,6 @@ +import React from 'react'; +import cn from 'classnames'; + +export default ({className}) => ( +