diff --git a/PLUGINS.md b/PLUGINS.md index 0d17ccd55..cf1eade31 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -280,6 +280,41 @@ send data to the client. If the type in question contains args, clients may subs For more information, see the [Apollo Docs](https://github.com/apollographql/graphql-subscriptions). +#### Field: `tokenUserNotFound` + +```js +tokenUserNotFound: async ({jwt, token}) => { + let profile = await someExternalService(token); + if (!profile) { + return null; + } + + let user = await UserModel.findOneAndUpdate({ + id: profile.id + }, { + id: profile.id, + username: profile.username, + lowercaseUsername: profile.username.toLowerCase(), + roles: [], + profiles: [] + }, { + setDefaultsOnInsert: true, + new: true, + upsert: true + }); + + return user; +} +``` + +The `tokenUserNotFound` hook allows auth integrations to hook into the event +when a valid token is provided but a user can't be found in the database that +matches the provided id. + +The function is async, and should return the user object that was created in the +database, or null if the user wasn't found. The `jwt` paramenter of the object +is the unpacked token, while `token` is the original jwt token string. + #### Field: `router` ```js diff --git a/bin/cli-assets b/bin/cli-assets index 166152baf..97b5b043c 100755 --- a/bin/cli-assets +++ b/bin/cli-assets @@ -5,7 +5,7 @@ */ const program = require('./commander'); -const parseDuration = require('parse-duration'); +const parseDuration = require('ms'); const Table = require('cli-table'); const AssetModel = require('../models/asset'); const mongoose = require('../services/mongoose'); diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 3f8d975df..37e09590d 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -42,3 +42,12 @@ export const changeUserDetailStatuses = (tab) => { } return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses}; }; + +export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS}); + +export const toggleSelectCommentInUserDetail = (id, active) => { + return { + type: active ? actions.SELECT_USER_DETAIL_COMMENT : actions.UNSELECT_USER_DETAIL_COMMENT, + id + }; +}; diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index aa8a51a24..374de616a 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -9,3 +9,6 @@ export const VIEW_USER_DETAIL = 'VIEW_USER_DETAIL'; export const HIDE_USER_DETAIL = 'HIDE_USER_DETAIL'; export const SET_SORT_ORDER = 'MODERATION_SET_SORT_ORDER'; export const CHANGE_USER_DETAIL_STATUSES = 'CHANGE_USER_DETAIL_STATUSES'; +export const SELECT_USER_DETAIL_COMMENT = 'SELECT_USER_DETAIL_COMMENT'; +export const UNSELECT_USER_DETAIL_COMMENT = 'UNSELECT_USER_DETAIL_COMMENT'; +export const CLEAR_USER_DETAIL_SELECTIONS = 'CLEAR_USER_DETAIL_SELECTIONS'; diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js index d0dfc5f9a..8cd7685f0 100644 --- a/client/coral-admin/src/index.js +++ b/client/coral-admin/src/index.js @@ -9,9 +9,10 @@ import App from './components/App'; import 'react-mdl/extra/material.js'; import './graphql'; -import {loadPluginsTranslations} from 'coral-framework/helpers/plugins'; +import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins'; loadPluginsTranslations(); +injectPluginsReducers(); render( diff --git a/client/coral-admin/src/reducers/index.js b/client/coral-admin/src/reducers/index.js index 3036d6271..04c8b6590 100644 --- a/client/coral-admin/src/reducers/index.js +++ b/client/coral-admin/src/reducers/index.js @@ -13,5 +13,5 @@ export default { community, moderation, install, - config + config, }; diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index 4201ffb06..1b95bdb90 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -1,4 +1,4 @@ -import {fromJS, Map} from 'immutable'; +import {fromJS, Map, Set} from 'immutable'; import * as actions from '../constants/moderation'; const initialState = fromJS({ @@ -10,6 +10,7 @@ const initialState = fromJS({ userDetailId: null, userDetailActiveTab: 'all', userDetailStatuses: ['NONE', 'ACCEPTED', 'REJECTED', 'PREMOD'], + userDetailSelectedIds: new Set(), banDialog: false, shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show', sortOrder: 'REVERSE_CHRONOLOGICAL', @@ -66,11 +67,19 @@ export default function moderation (state = initialState, action) { case actions.VIEW_USER_DETAIL: return state.set('userDetailId', action.userId); case actions.HIDE_USER_DETAIL: - return state.set('userDetailId', null); + return state + .set('userDetailId', null) + .update('userDetailSelectedIds', (set) => set.clear()); + case actions.CLEAR_USER_DETAIL_SELECTIONS: + return state.update('userDetailSelectedIds', (set) => set.clear()); case actions.CHANGE_USER_DETAIL_STATUSES: return state .set('userDetailActiveTab', action.tab) .set('userDetailStatuses', action.statuses); + case actions.SELECT_USER_DETAIL_COMMENT: + return state.update('userDetailSelectedIds', (set) => set.add(action.id)); + case actions.UNSELECT_USER_DETAIL_COMMENT: + return state.update('userDetailSelectedIds', (set) => set.delete(action.id)); case actions.SET_SORT_ORDER: return state.set('sortOrder', action.order); default : diff --git a/client/coral-admin/src/routes/Configure/components/Configure.css b/client/coral-admin/src/routes/Configure/components/Configure.css index 3d5750693..22e46518c 100644 --- a/client/coral-admin/src/routes/Configure/components/Configure.css +++ b/client/coral-admin/src/routes/Configure/components/Configure.css @@ -99,7 +99,7 @@ .inlineTextfield { border-color: #ccc; border-style: solid; - border-width: 0px 0px 1px 0px; + border-width: 0px 0px 1px 0px; text-align: center; font-size: inherit; } @@ -108,7 +108,7 @@ outline: none; } -.charCountTexfield { +.charCountTexfield, .editCommentTimeframeTextfield { width: 4em; padding: 0px; } diff --git a/client/coral-admin/src/routes/Configure/components/ModerationSettings.js b/client/coral-admin/src/routes/Configure/components/ModerationSettings.js index 6b4795721..99f3435e2 100644 --- a/client/coral-admin/src/routes/Configure/components/ModerationSettings.js +++ b/client/coral-admin/src/routes/Configure/components/ModerationSettings.js @@ -25,12 +25,6 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => { const on = styles.enabledSetting; const off = styles.disabledSetting; - const onChangeEditCommentWindowLength = (e) => { - const value = e.target.value; - const valueAsNumber = parseFloat(value); - const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000); - updateSettings({editCommentWindowLength: milliseconds || value}); - }; return (
@@ -76,27 +70,6 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => { bannedWords={settings.wordlist.banned} suspectWords={settings.wordlist.suspect} onChangeWordlist={onChangeWordlist} /> - - {/* Edit Comment Timeframe */} - -
{t('configure.edit_comment_timeframe_heading')}
-

- {t('configure.edit_comment_timeframe_text_pre')} -   - -   - {t('configure.edit_comment_timeframe_text_post')} -

-
); }; diff --git a/client/coral-admin/src/routes/Configure/components/StreamSettings.js b/client/coral-admin/src/routes/Configure/components/StreamSettings.js index 41c62c153..2de82ec1f 100644 --- a/client/coral-admin/src/routes/Configure/components/StreamSettings.js +++ b/client/coral-admin/src/routes/Configure/components/StreamSettings.js @@ -61,6 +61,13 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => { } }; +const updateEditCommentWindowLength = (updateSettings) => (e) => { + const value = e.target.value; + const valueAsNumber = parseFloat(value); + const milliseconds = (!isNaN(valueAsNumber)) && (valueAsNumber * 1000); + updateSettings({editCommentWindowLength: milliseconds || value}); +}; + const StreamSettings = ({updateSettings, settingsError, settings, errors}) => { // just putting this here for shorthand below @@ -132,6 +139,25 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => { + {/* Edit Comment Timeframe */} + +
{t('configure.edit_comment_timeframe_heading')}
+

+ {t('configure.edit_comment_timeframe_text_pre')} +   + +   + {t('configure.edit_comment_timeframe_text_post')} +

+
{ const links = linkify.getMatches(comment.body); @@ -48,10 +50,17 @@ const Comment = ({ }) .concat(linkText); + let selectionStateCSS; + if (minimal) { + selectionStateCSS = selected ? styles.minimalSelection : ''; + } else { + selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; + } + return (
  • @@ -63,6 +72,16 @@ const Comment = ({ ) } + { + minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && ( + toggleSelect(e.target.value, e.target.checked)} /> + ) + } {timeago(comment.created_at || Date.now() - props.index * 60 * 1000)} @@ -187,6 +206,7 @@ Comment.propTypes = { showBanUserDialog: PropTypes.func.isRequired, showSuspendUserDialog: PropTypes.func.isRequired, currentUserId: PropTypes.string.isRequired, + toggleSelect: PropTypes.func, comment: PropTypes.shape({ body: PropTypes.string.isRequired, action_summaries: PropTypes.array, diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.css b/client/coral-admin/src/routes/Moderation/components/UserDetail.css index 1119be8c4..d4b5f0db7 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.css +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.css @@ -41,8 +41,11 @@ } .commentStatuses { - padding: 0; + padding: 10px 0 0 0; + margin: 0; + height: 52px; list-style: none; + box-sizing: border-box; li { display: inline-block; @@ -56,3 +59,24 @@ font-weight: bold; border-bottom: 3px solid #F36451; } + +.bulkActionGroup { + height: 52px; + background-color: #efefef; + + i { + margin-right: 0; + } + + .bulkAction { + display: inline-block; + width: 48px; + height: 48px; + transform: scale(.7); + min-width: 0; + } + + .bulkAction:last-child { + margin-left: -10px; + } +} diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.js b/client/coral-admin/src/routes/Moderation/components/UserDetail.js index c461220c8..0fb355486 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.js @@ -16,6 +16,10 @@ export default class UserDetail extends React.Component { showSuspendUserDialog: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, rejectComment: PropTypes.func.isRequired, + changeStatus: PropTypes.func.isRequired, + toggleSelect: PropTypes.func.isRequired, + bulkAccept: PropTypes.func.isRequired, + bulkReject: PropTypes.func.isRequired, } copyPermalink = () => { @@ -28,12 +32,24 @@ export default class UserDetail extends React.Component { } } - changeStatus = (tab) => { - if (tab === 'all') { - this.props.changeStatus('all'); - } else if (tab === 'rejected') { - this.props.changeStatus('rejected'); - } + rejectThenReload = (info) => { + this.props.rejectComment(info).then(() => { + this.props.data.refetch(); + }); + } + + acceptThenReload = (info) => { + this.props.acceptComment(info).then(() => { + this.props.data.refetch(); + }); + } + + showAll = () => { + this.props.changeStatus('all'); + } + + showRejected = () => { + this.props.changeStatus('rejected'); } render () { @@ -44,13 +60,17 @@ export default class UserDetail extends React.Component { rejectedComments, comments: {nodes} }, - moderation: {userDetailActiveTab: tab}, + moderation: { + userDetailActiveTab: tab, + userDetailSelectedIds: selectedIds + }, bannedWords, suspectWords, + toggleSelect, + bulkAccept, + bulkReject, showBanUserDialog, showSuspendUserDialog, - acceptComment, - rejectComment, hideUserDetail } = this.props; const localProfile = user.profiles.find((p) => p.provider === 'local'); @@ -94,14 +114,38 @@ export default class UserDetail extends React.Component {

    {`${(rejectedPercent).toFixed(1)}%`}

    -
      -
    • All
    • -
    • Rejected
    • -
    + { + selectedIds.length === 0 + ? ( +
      +
    • All
    • +
    • Rejected
    • +
    + ) + : ( +
    + + + {`${selectedIds.length} comments selected`} +
    + ) + } +
    { nodes.map((comment, i) => { const status = comment.action_summaries ? 'FLAGGED' : comment.status; + const selected = selectedIds.indexOf(comment.id) !== -1; return ; diff --git a/client/coral-admin/src/routes/Moderation/components/styles.css b/client/coral-admin/src/routes/Moderation/components/styles.css index e7bea9430..d10311f74 100644 --- a/client/coral-admin/src/routes/Moderation/components/styles.css +++ b/client/coral-admin/src/routes/Moderation/components/styles.css @@ -185,10 +185,6 @@ span { padding: 0 14px; } - &:hover { - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); - } - &:last-child { border-bottom: none; } @@ -291,7 +287,6 @@ span { @media (--big-viewport) { .listItem { - border: 1px solid #e0e0e0; margin-bottom: 30px; &:last-child { @@ -460,3 +455,15 @@ span { position: relative; } } + +.minimal { + margin: 0; +} + +.minimalSelection { + background-color: #ecf4ff; +} + +.bulkSelectInput { + cursor: pointer; +} diff --git a/client/coral-admin/src/routes/Moderation/containers/UserDetail.js b/client/coral-admin/src/routes/Moderation/containers/UserDetail.js index 9afd10efb..0e36ed51a 100644 --- a/client/coral-admin/src/routes/Moderation/containers/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/containers/UserDetail.js @@ -6,7 +6,12 @@ import UserDetail from '../components/UserDetail'; import withQuery from 'coral-framework/hocs/withQuery'; import {getSlotsFragments} from 'coral-framework/helpers/plugins'; import {getDefinitionName} from 'coral-framework/utils'; -import {changeUserDetailStatuses} from 'coral-admin/src/actions/moderation'; +import { + changeUserDetailStatuses, + clearUserDetailSelections, + toggleSelectCommentInUserDetail +} from 'coral-admin/src/actions/moderation'; +import {withSetCommentStatus} from 'coral-framework/graphql/mutations'; import Comment from './Comment'; const commentConnectionFragment = gql` @@ -31,12 +36,37 @@ class UserDetailContainer extends React.Component { hideUserDetail: PropTypes.func.isRequired } + // status can be 'ACCEPTED' or 'REJECTED' + bulkSetCommentStatus = (status) => { + const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => { + return this.props.setCommentStatus({commentId, status}); + }); + + Promise.all(changes).then(() => { + this.props.data.refetch(); // some comments may have moved out of this tab + this.props.clearUserDetailSelections(); // un-select everything + }); + } + + bulkReject = () => { + this.bulkSetCommentStatus('REJECTED'); + } + + bulkAccept = () => { + this.bulkSetCommentStatus('ACCEPTED'); + } + render () { if (!('user' in this.props.root)) { return null; } - return ; + return ; } } @@ -79,10 +109,15 @@ const mapStateToProps = (state) => ({ }); const mapDispatchToProps = (dispatch) => ({ - ...bindActionCreators({changeUserDetailStatuses}, dispatch) + ...bindActionCreators({ + changeUserDetailStatuses, + clearUserDetailSelections, + toggleSelectCommentInUserDetail + }, dispatch) }); export default compose( connect(mapStateToProps, mapDispatchToProps), withUserDetailQuery, + withSetCommentStatus, )(UserDetailContainer); diff --git a/client/coral-admin/src/services/store.js b/client/coral-admin/src/services/store.js index 2ab9d3750..9815a0f70 100644 --- a/client/coral-admin/src/services/store.js +++ b/client/coral-admin/src/services/store.js @@ -14,14 +14,18 @@ if (window.devToolsExtension) { middlewares.push(window.devToolsExtension()); } +const coralReducers = { + ...mainReducer, + apollo: client.reducer() +}; + const store = createStore( - combineReducers({ - ...mainReducer, - apollo: client.reducer() - }), + combineReducers(coralReducers), {}, compose(...middlewares) ); +store.coralReducers = coralReducers; + window.coralStore = store; export default store; diff --git a/client/coral-embed-stream/src/actions/stream.js b/client/coral-embed-stream/src/actions/stream.js index 81b57a0fe..6a9e47838 100644 --- a/client/coral-embed-stream/src/actions/stream.js +++ b/client/coral-embed-stream/src/actions/stream.js @@ -2,7 +2,6 @@ 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]; diff --git a/client/coral-embed-stream/src/components/Comment.css b/client/coral-embed-stream/src/components/Comment.css index e0b21a34d..f2b12feb5 100644 --- a/client/coral-embed-stream/src/components/Comment.css +++ b/client/coral-embed-stream/src/components/Comment.css @@ -101,3 +101,13 @@ .commentInfoBar { float: right; } + +@keyframes enter { + 0% {background-color: rgba(0, 0, 0, 0);} + 50% {background-color: rgba(255,255,0, 0.2);} + 100% {background-color: rgba(0, 0, 0, 0);} +} + +.enter { + animation: enter 1000ms; +} diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 9d0c9627f..53be600dc 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -1,5 +1,4 @@ import React, {PropTypes} from 'react'; -import cn from 'classnames'; import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton'; import AuthorName from 'coral-plugin-author-name/AuthorName'; @@ -8,6 +7,9 @@ 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 {TransitionGroup} from 'react-transition-group'; +import cn from 'classnames'; + import { BestButton, IfUserCanModifyBest, @@ -26,6 +28,41 @@ import styles from './Comment.css'; const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF'); const hasTag = (tags, lookupTag) => !!tags.filter((tag) => tag.name === lookupTag).length; +const hasComment = (nodes, id) => nodes.some((node) => node.id === id); + +// resetCursors will return the id cursors of the first and second newest comment in +// the current reply list. The cursors are used to dertermine which +// comments to show. The spare cursor functions as a backup in case one +// of the comments gets deleted. +function resetCursors(state, props) { + const replies = props.comment.replies; + if (replies && replies.nodes.length) { + const idCursors = [replies.nodes[replies.nodes.length - 1].id]; + if (replies.nodes.length >= 2) { + idCursors.push(replies.nodes[replies.nodes.length - 2].id); + } + return {idCursors}; + } + return {idCursors: []}; +} + +// invalidateCursor is called whenever a comment is removed which is referenced +// by one of the 2 id cursors. It returns a new set of id cursors calculated +// using the help of the backup cursor. +function invalidateCursor(invalidated, state, props) { + const alt = invalidated === 1 ? 0 : 1; + const replies = props.comment.replies; + const idCursors = []; + if (state.idCursors[alt]) { + idCursors.push(state.idCursors[alt]); + const index = replies.nodes.findIndex((node) => node.id === idCursors[0]); + const prevInLine = replies.nodes[index - 1]; + if (prevInLine) { + idCursors.push(prevInLine.id); + } + } + return {idCursors}; +} // hold actions links (e.g. Reply) along the comment footer const ActionButton = ({children}) => { @@ -49,9 +86,47 @@ export default class Comment extends React.Component { // Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment) isEditing: false, replyBoxVisible: false, + animateEnter: false, + ...resetCursors({}, props), }; } + componentWillReceiveProps(next) { + const {comment: {replies: prevReplies}} = this.props; + const {comment: {replies: nextReplies}} = next; + if ( + prevReplies && nextReplies && + nextReplies.nodes.length < prevReplies.nodes.length + ) { + + // Invalidate first cursor if referenced comment was removed. + if (this.state.idCursors[0] && !hasComment(nextReplies.nodes, this.state.idCursors[0])) { + this.setState(invalidateCursor(0, this.state, next)); + } + + // Invalidate second cursor if referenced comment was removed. + if (this.state.idCursors[1] && !hasComment(nextReplies.nodes, this.state.idCursors[1])) { + this.setState(invalidateCursor(1, this.state, next)); + } + } + } + + componentWillEnter(callback) { + callback(); + const userId = this.props.currentUser ? this.props.currentUser.id : null; + if (this.props.comment.id.indexOf('pending') >= 0) { + return; + } + if (userId && this.props.comment.user.id === userId) { + + // This comment was just added by currentUser. + if (Date.now() - Number(new Date(this.props.comment.created_at)) < 30 * 1000) { + return; + } + } + this.setState({animateEnter: true}); + } + static propTypes = { reactKey: PropTypes.string.isRequired, @@ -67,6 +142,7 @@ export default class Comment extends React.Component { addNotification: PropTypes.func.isRequired, postComment: PropTypes.func.isRequired, depth: PropTypes.number.isRequired, + liveUpdates: PropTypes.bool.isRequired, asset: PropTypes.shape({ id: PropTypes.string, title: PropTypes.string, @@ -127,6 +203,50 @@ export default class Comment extends React.Component { } } + hasIgnoredReplies() { + return this.props.comment.replies && + this.props.comment.replies.nodes.some((reply) => this.props.commentIsIgnored(reply)); + } + + loadNewReplies = () => { + const {replies, replyCount, id} = this.props.comment; + if (replyCount > replies.nodes.length) { + this.props.loadMore(id).then(() => { + this.setState(resetCursors(this.state, this.props)); + }); + return; + } + this.setState(resetCursors); + }; + + // getVisibileReplies returns a list containing comments + // which were authored by current user or comes before the `idCursor`. + getVisibileReplies() { + const {comment: {replies}, currentUser, liveUpdates} = this.props; + const idCursor = this.state.idCursors[0]; + const userId = currentUser ? currentUser.id : null; + + if (!replies) { + return []; + } + + if (liveUpdates) { + return replies.nodes; + } + + const view = []; + let pastCursor = false; + replies.nodes.forEach((comment) => { + if (idCursor && !pastCursor || comment.user.id === userId) { + view.push(comment); + } + if (comment.id === idCursor) { + pastCursor = true; + } + }); + return view; + } + componentDidMount() { this._isMounted = true; if (this.editWindowExpiryTimeout) { @@ -156,26 +276,30 @@ export default class Comment extends React.Component { comment, postFlag, parentId, - loadMore, ignoreUser, highlighted, postComment, currentUser, + postDontAgree, + setActiveReplyBox, + activeReplyBox, deleteAction, disableReply, maxCharCount, - postDontAgree, addCommentTag, - activeReplyBox, addNotification, charCountEnable, showSignInDialog, removeCommentTag, + liveUpdates, commentIsIgnored, - setActiveReplyBox, commentClassNames = [] } = this.props; + const view = this.getVisibileReplies(); + + const hasMoreComments = comment.replies && (comment.replies.hasNextPage || comment.replies.nodes.length > view.length); + const replyCount = this.hasIgnoredReplies() ? '' : comment.replyCount; const flagSummary = getActionSummary('FlagActionSummary', comment); const dontAgreeSummary = getActionSummary( 'DontAgreeActionSummary', @@ -241,7 +365,7 @@ export default class Comment extends React.Component { let res = []; // Adding classNames based on tags - Object.keys(className).map((cn) => { + Object.keys(className).forEach((cn) => { const condition = className[cn]; condition.tags.forEach((tag) => { if (hasTag(comment.tags, tag)) { @@ -257,6 +381,7 @@ export default class Comment extends React.Component { return (
    - + {isStaff(comment.tags) ? Staff : null} {commentIsBest(comment) ? : null } - - + + { (comment.editing && comment.editing.edited) ?  (Edited) @@ -402,46 +527,49 @@ export default class Comment extends React.Component { assetId={asset.id} /> : null} - {comment.replies && - comment.replies.nodes.map((reply) => { - return commentIsIgnored(reply) - ? - : ; - })} - {comment.replies && -
    - comment.replies.nodes.length} - loadMore={() => loadMore(comment.id)} - /> -
    } + + + {view.map((reply) => { + return commentIsIgnored(reply) + ? + : ; + })} + +
    + +
    ); } diff --git a/client/coral-embed-stream/src/components/Embed.js b/client/coral-embed-stream/src/components/Embed.js index 3c94d5f81..145e8340a 100644 --- a/client/coral-embed-stream/src/components/Embed.js +++ b/client/coral-embed-stream/src/components/Embed.js @@ -41,10 +41,10 @@ export default class Embed extends React.Component { return (
    - - - {t('framework.my_profile')} - {t('framework.configure_stream')} + + + {t('framework.my_profile')} + {t('framework.configure_stream')} {commentId && : null } @@ -25,8 +17,7 @@ const NewCount = (props) => { }; NewCount.propTypes = { - commentCount: PropTypes.number.isRequired, - commentCountCache: PropTypes.number, + count: PropTypes.number.isRequired, loadMore: PropTypes.func.isRequired, }; diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index b99b23b70..98cbea224 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -1,7 +1,6 @@ import React, {PropTypes} from 'react'; import LoadMore from './LoadMore'; -import NewCount from './NewCount'; -import Comment from '../containers/Comment'; +import Comment from '../components/Comment'; import SuspendedAccount from './SuspendedAccount'; import Slot from 'coral-framework/components/Slot'; import InfoBox from 'coral-plugin-infobox/InfoBox'; @@ -13,8 +12,81 @@ import t, {timeago} from 'coral-framework/services/i18n'; import CommentBox from 'coral-plugin-commentbox/CommentBox'; import QuestionBox from 'coral-plugin-questionbox/QuestionBox'; import IgnoredCommentTombstone from './IgnoredCommentTombstone'; +import NewCount from './NewCount'; +import {TransitionGroup} from 'react-transition-group'; + +const hasComment = (nodes, id) => nodes.some((node) => node.id === id); + +// resetCursors will return the id cursors of the first and second comment of +// the current comment list. The cursors are used to dertermine which +// comments to show. The spare cursor functions as a backup in case one +// of the comments gets deleted. +function resetCursors(state, props) { + const comments = props.root.asset.comments; + if (comments && comments.nodes.length) { + const idCursors = [comments.nodes[0].id]; + if (comments.nodes[1]) { + idCursors.push(comments.nodes[1].id); + } + return {idCursors}; + } + return {idCursors: []}; +} + +// invalidateCursor is called whenever a comment is removed which is referenced +// by one of the 2 id cursors. It returns a new set of id cursors calculated +// using the help of the backup cursor. +function invalidateCursor(invalidated, state, props) { + const alt = invalidated === 1 ? 0 : 1; + const comments = props.root.asset.comments; + const idCursors = []; + if (state.idCursors[alt]) { + idCursors.push(state.idCursors[alt]); + const index = comments.nodes.findIndex((node) => node.id === idCursors[0]); + const nextInLine = comments.nodes[index + 1]; + if (nextInLine) { + idCursors.push(nextInLine.id); + } + } + return {idCursors}; +} class Stream extends React.Component { + + constructor(props) { + super(props); + this.state = resetCursors(this.state, props); + } + + componentWillReceiveProps(next) { + const {root: {asset: {comments: prevComments}}} = this.props; + const {root: {asset: {comments: nextComments}}} = next; + + if (!prevComments && nextComments) { + this.setState(resetCursors); + return; + } + if ( + prevComments && nextComments && + nextComments.nodes.length < prevComments.nodes.length + ) { + + // Invalidate first cursor if referenced comment was removed. + if (this.state.idCursors[0] && !hasComment(nextComments.nodes, this.state.idCursors[0])) { + this.setState(invalidateCursor(0, this.state, next)); + } + + // Invalidate second cursor if referenced comment was removed. + if (this.state.idCursors[1] && !hasComment(nextComments.nodes, this.state.idCursors[1])) { + this.setState(invalidateCursor(1, this.state, next)); + } + } + } + + viewNewComments = () => { + this.setState(resetCursors); + }; + setActiveReplyBox = (reactKey) => { if (!this.props.auth.user) { this.props.showSignInDialog(); @@ -23,6 +95,30 @@ class Stream extends React.Component { } }; + // getVisibileComments returns a list containing comments + // which were authored by current user or comes after the `idCursor`. + getVisibleComments() { + const {root: {asset: {comments}}, auth: {user}} = this.props; + const idCursor = this.state.idCursors[0]; + const userId = user ? user.id : null; + + if (!comments) { + return []; + } + + const view = []; + let pastCursor = false; + comments.nodes.forEach((comment) => { + if (comment.id === idCursor) { + pastCursor = true; + } + if (pastCursor || comment.user.id === userId) { + view.push(comment); + } + }); + return view; + } + render() { const { commentClassNames, @@ -38,9 +134,9 @@ class Stream extends React.Component { auth: {loggedIn, user}, removeCommentTag, pluginProps, - commentCountCache, editName } = this.props; + const view = this.getVisibleComments(); const open = asset.closedAt === null; // even though the permalinked comment is the highlighted one, we're displaying its parent + replies @@ -97,8 +193,6 @@ class Stream extends React.Component { postComment={this.props.postComment} appendItemArray={this.props.appendItemArray} updateItem={this.props.updateItem} - setCommentCountCache={this.props.setCommentCountCache} - commentCountCache={commentCountCache} assetId={asset.id} premod={asset.settings.moderation} isReply={false} @@ -145,16 +239,15 @@ class Stream extends React.Component { charCountEnable={asset.settings.charCountEnable} maxCharCount={asset.settings.charCount} editComment={this.props.editComment} + liveUpdates={true} /> :
    -
    - {comments && comments.nodes.map((comment) => { + + {view.map((comment) => { return commentIsIgnored(comment) ? : ; })} -
    + pym.scrollParentToChildEl('coralStream'), 0); @@ -87,7 +80,6 @@ export const withEmbedQuery = withQuery(EMBED_QUERY, { const mapStateToProps = (state) => ({ auth: state.auth.toJS(), - commentCountCache: state.stream.commentCountCache, commentId: state.stream.commentId, assetId: state.stream.assetId, assetUrl: state.stream.assetUrl, @@ -103,7 +95,6 @@ const mapDispatchToProps = (dispatch) => setActiveTab, viewAllComments, fetchAssetSuccess, - setCommentCountCache }, dispatch ); diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index 7788961b0..823a7971d 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -2,34 +2,94 @@ import React from 'react'; import {gql, compose} from 'react-apollo'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {NEW_COMMENT_COUNT_POLL_INTERVAL, ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream'; +import {ADDTL_COMMENTS_ON_LOAD_MORE} from '../constants/stream'; import { withPostComment, withPostFlag, withPostDontAgree, withDeleteAction, withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment, } from 'coral-framework/graphql/mutations'; -import update from 'immutability-helper'; import {notificationActions, authActions} from 'coral-framework'; import {editName} from 'coral-framework/actions/user'; -import {setCommentCountCache, setActiveReplyBox} from '../actions/stream'; +import {setActiveReplyBox} from '../actions/stream'; import Stream from '../components/Stream'; import Comment from './Comment'; import {withFragments} from 'coral-framework/hocs'; +import {Spinner} from 'coral-ui'; import {getDefinitionName} from 'coral-framework/utils'; +import { + findCommentInEmbedQuery, + insertCommentIntoEmbedQuery, + removeCommentFromEmbedQuery, + insertFetchedCommentsIntoEmbedQuery, +} from '../graphql/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, + subscriptions = []; - // Apollo requires this, even though we don't use it... - updateQuery: (data) => data, + subscribeToUpdates() { + const sub1 = this.props.data.subscribeToMore({ + document: COMMENTS_EDITED_SUBSCRIPTION, + variables: { + assetId: this.props.root.asset.id, + }, + updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => { + + // Ignore mutations from me. + // TODO: need way to detect mutations created by this client, and allow mutations from other clients. + if (this.props.auth.user && commentEdited.user.id === this.props.auth.user.id) { + return prev; + } + + // Exit when comment is not in the query. + if (!findCommentInEmbedQuery(prev, commentEdited.id)) { + return prev; + } + + if (['PREMOD', 'REJECTED'].includes(commentEdited.status)) { + return removeCommentFromEmbedQuery(prev, commentEdited.id); + } + }, }); - }; + + const sub2 = this.props.data.subscribeToMore({ + document: COMMENTS_ADDED_SUBSCRIPTION, + variables: { + assetId: this.props.root.asset.id, + }, + updateQuery: (prev, {subscriptionData: {data: {commentAdded}}}) => { + + // Ignore mutations from me. + // TODO: need way to detect mutations created by this client, and allow mutations from other clients. + if (this.props.auth.user && commentAdded.user.id === this.props.auth.user.id) { + return prev; + } + + // Exit if author is ignored. + if ( + this.props.root.me && + this.props.root.me.ignoredUsers.some(({id}) => id === commentAdded.user.id)) { + return prev; + } + + // Exit when comment is already in the query. + if (findCommentInEmbedQuery(prev, commentAdded.id)) { + return prev; + } + + return insertCommentIntoEmbedQuery(prev, commentAdded); + } + }); + + this.subscriptions.push(sub1, sub2); + } + + unsubscribe() { + this.subscriptions.forEach((unsubscribe) => unsubscribe()); + this.subscriptions = []; + } loadNewReplies = (parent_id) => { const comment = this.props.root.comment @@ -47,86 +107,11 @@ class StreamContainer extends React.Component { excludeIgnored: this.props.data.variables.excludeIgnored, }, updateQuery: (prev, {fetchMoreResult:{comments}}) => { - if (!comments.nodes.length) { - return prev; - } - - const updateNode = (node) => - update(node, { - replies: { - endCursor: {$set: comments.endCursor}, - nodes: {$apply: (nodes) => nodes - .concat(comments.nodes.filter( - (comment) => !nodes.some((node) => node.id === comment.id) - )) - .sort(ascending) - }, - }, - }); - - // highlighted comment. - if (prev.comment) { - if (prev.comment.parent) { - return update(prev, { - comment: { - parent: {$apply: (comment) => updateNode(comment)}, - } - }); - } - return update(prev, { - comment: {$apply: (comment) => updateNode(comment)}, - }); - } - - return update(prev, { - asset: { - comments: { - nodes: { - $apply: (nodes) => nodes.map( - (node) => node.id !== parent_id - ? node - : updateNode(node) - ) - }, - }, - }, - }); + return insertFetchedCommentsIntoEmbedQuery(prev, comments, parent_id); }, }); } - loadNewComments = () => { - return this.props.data.fetchMore({ - query: LOAD_MORE_QUERY, - variables: { - limit: ADDTL_COMMENTS_ON_LOAD_MORE, - cursor: this.props.root.asset.comments.startCursor, - parent_id: null, - asset_id: this.props.root.asset.id, - sort: 'CHRONOLOGICAL', - excludeIgnored: this.props.data.variables.excludeIgnored, - }, - updateQuery: (prev, {fetchMoreResult:{comments}}) => { - if (!comments.nodes.length) { - return prev; - } - return update(prev, { - asset: { - comments: { - startCursor: {$set: comments.endCursor}, - nodes: {$apply: (nodes) => comments.nodes.filter( - (comment) => !nodes.some((node) => node.id === comment.id) - ) - .concat(nodes) - .sort(descending) - }, - }, - }, - }); - }, - }); - }; - loadMoreComments = () => { return this.props.data.fetchMore({ query: LOAD_MORE_QUERY, @@ -139,78 +124,77 @@ class StreamContainer extends React.Component { excludeIgnored: this.props.data.variables.excludeIgnored, }, updateQuery: (prev, {fetchMoreResult:{comments}}) => { - if (!comments.nodes.length) { - return prev; - } - - return update(prev, { - asset: { - comments: { - hasNextPage: {$set: comments.hasNextPage}, - endCursor: {$set: comments.endCursor}, - nodes: {$push: comments.nodes}, - }, - }, - }); + return insertFetchedCommentsIntoEmbedQuery(prev, comments); }, }); }; componentDidMount() { if (this.props.previousTab) { - this.props.data.refetch() - .then(({data: {asset: {commentCount}}}) => { - return this.props.setCommentCountCache(commentCount); - }); + this.props.data.refetch(); } - this.countPoll = setInterval(() => { - this.getCounts(this.props.data.variables); - }, NEW_COMMENT_COUNT_POLL_INTERVAL); + this.subscribeToUpdates(); } componentWillUnmount() { + this.unsubscribe(); clearInterval(this.countPoll); } render() { + if (this.props.refetching) { + return ; + } return ; } } -const ascending = (a, b) => { - const dateA = new Date(a.created_at); - const dateB = new Date(b.created_at); - if (dateA < dateB) { return -1; } - if (dateA > dateB) { return 1; } - return 0; -}; +const commentFragment = gql` + fragment CoralEmbedStream_Stream_comment on Comment { + id + ...${getDefinitionName(Comment.fragments.comment)} + replyCount(excludeIgnored: $excludeIgnored) + replies(excludeIgnored: $excludeIgnored) { + nodes { + id + ...${getDefinitionName(Comment.fragments.comment)} + } + hasNextPage + startCursor + endCursor + } + } + ${Comment.fragments.comment} +`; -const descending = (a, b) => ascending(a, b) * -1; - -const LOAD_COMMENT_COUNTS_QUERY = gql` - query CoralEmbedStream_LoadCommentCounts($assetUrl: String, , $commentId: ID!, $assetId: ID, $hasComment: Boolean!, $excludeIgnored: Boolean) { - comment(id: $commentId) @include(if: $hasComment) { - id +const COMMENTS_ADDED_SUBSCRIPTION = gql` + subscription onCommentAdded($assetId: ID!, $excludeIgnored: Boolean){ + commentAdded(asset_id: $assetId){ parent { id - replyCount(excludeIgnored: $excludeIgnored) } - replyCount(excludeIgnored: $excludeIgnored) + ...CoralEmbedStream_Stream_comment } - asset(id: $assetId, url: $assetUrl) { + } + ${commentFragment} +`; + +const COMMENTS_EDITED_SUBSCRIPTION = gql` + subscription onCommentEdited($assetId: ID!){ + commentEdited(asset_id: $assetId){ id - commentCount(excludeIgnored: $excludeIgnored) - comments(limit: 10) @skip(if: $hasComment) { - nodes { - id - replyCount(excludeIgnored: $excludeIgnored) - } + body + status + editing { + edited + } + user { + id } } } @@ -241,24 +225,6 @@ const LOAD_MORE_QUERY = gql` ${Comment.fragments.comment} `; -const commentFragment = gql` - fragment CoralEmbedStream_Stream_comment on Comment { - id - ...${getDefinitionName(Comment.fragments.comment)} - replyCount(excludeIgnored: $excludeIgnored) - replies { - nodes { - id - ...${getDefinitionName(Comment.fragments.comment)} - } - hasNextPage - startCursor - endCursor - } - } - ${Comment.fragments.comment} -`; - const fragments = { root: gql` fragment CoralEmbedStream_Stream_root on RootQuery { @@ -316,6 +282,7 @@ const fragments = { const mapStateToProps = (state) => ({ auth: state.auth.toJS(), + refetching: state.embed.refetching, commentCountCache: state.stream.commentCountCache, activeReplyBox: state.stream.activeReplyBox, commentId: state.stream.commentId, @@ -332,7 +299,6 @@ const mapDispatchToProps = (dispatch) => addNotification, setActiveReplyBox, editName, - setCommentCountCache, }, dispatch); export default compose( diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js index 49ef6c620..d9d4be12e 100644 --- a/client/coral-embed-stream/src/graphql/index.js +++ b/client/coral-embed-stream/src/graphql/index.js @@ -91,6 +91,9 @@ const extension = { edited editableUntil } + parent { + id + } } `, }, @@ -144,6 +147,9 @@ const extension = { tags: tags.map((t) => ({name: t, __typename: 'Tag'})), status: null, replyCount: 0, + parent: parent_id + ? {id: parent_id} + : null, replies: { __typename: 'CommentConnection', nodes: [], @@ -165,7 +171,7 @@ const extension = { if (prev.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') { return prev; } - return insertCommentIntoEmbedQuery(prev, parent_id, comment); + return insertCommentIntoEmbedQuery(prev, comment); }, } }), diff --git a/client/coral-embed-stream/src/graphql/utils.js b/client/coral-embed-stream/src/graphql/utils.js index 163432479..eff1e2439 100644 --- a/client/coral-embed-stream/src/graphql/utils.js +++ b/client/coral-embed-stream/src/graphql/utils.js @@ -1,11 +1,33 @@ import update from 'immutability-helper'; -function findAndInsertComment(parent, id, comment) { - const [connectionField, countField, action] = parent.comments +function applyToCommentsOrigin(root, callback) { + if (root.comment) { + if (root.comment.parent) { + return update(root, { + comment: { + parent: { + $apply: (node) => callback(node), + }, + }, + }); + } + return update(root, { + comment: { + $apply: (node) => callback(node), + }, + }); + } + return update(root, { + asset: {$apply: (asset) => callback(asset)}, + }); +} + +function findAndInsertComment(parent, comment) { + const [connectionField, countField, action] = parent.__typename === 'Asset' ? ['comments', 'commentCount', '$unshift'] : ['replies', 'replyCount', '$push']; - if (!id || parent.id === id) { + if (!comment.parent || parent.id === comment.parent.id) { return update(parent, { [connectionField]: { nodes: {[action]: [comment]}, @@ -21,13 +43,13 @@ function findAndInsertComment(parent, id, comment) { [connectionField]: { nodes: { $apply: (nodes) => - nodes.map((node) => findAndInsertComment(node, id, comment)) + nodes.map((node) => findAndInsertComment(node, comment)) }, }, }); } -export function insertCommentIntoEmbedQuery(root, id, comment) { +export function insertCommentIntoEmbedQuery(root, comment) { // Increase total comment count by one. root = update(root, { @@ -35,30 +57,11 @@ export function insertCommentIntoEmbedQuery(root, id, comment) { totalCommentCount: {$apply: (c) => c + 1}, }, }); - - if (root.comment) { - if (root.comment.parent) { - return update(root, { - comment: { - parent: { - $apply: (node) => findAndInsertComment(node, id, comment), - }, - }, - }); - } - return update(root, { - comment: { - $apply: (node) => findAndInsertComment(node, id, comment), - }, - }); - } - return update(root, { - asset: {$apply: (asset) => findAndInsertComment(asset, id, comment)}, - }); + return applyToCommentsOrigin(root, (origin) => findAndInsertComment(origin, comment)); } function findAndRemoveComment(parent, id) { - const [connectionField, countField] = parent.comments + const [connectionField, countField] = parent.__typename === 'Asset' ? ['comments', 'commentCount'] : ['replies', 'replyCount']; @@ -78,7 +81,7 @@ function findAndRemoveComment(parent, id) { }; if (parent[countField] && next.length !== connection.nodes.length) { - changes[countField] = {$set: changes[countField] - 1}; + changes[countField] = {$set: parent[countField] - 1}; } return update(parent, changes); } @@ -91,24 +94,88 @@ export function removeCommentFromEmbedQuery(root, id) { totalCommentCount: {$apply: (c) => c - 1}, }, }); + return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id)); +} - if (root.comment) { - if (root.comment.parent) { - return update(root, { - comment: { - parent: { - $apply: (node) => findAndRemoveComment(node, id), - }, - }, - }); +function findComment(nodes, callback) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (callback(node)) { + return node; } - return update(root, { - comment: { - $apply: (node) => findAndRemoveComment(node, id), + if (node.replies) { + const find = findComment(node.replies.nodes, callback); + if (find){ + return find; + } + } + } + return false; +} + +export function findCommentInEmbedQuery(root, callbackOrId) { + let callback = callbackOrId; + if (typeof callbackOrId === 'string') { + callback = (node) => node.id === callbackOrId; + } + if (root.comment) { + if (callback(root.comment)) { + return root.comment; + } + if (root.comment.parent && callback(root.comment.parent)) { + return root.comment.parent; + } + } + if (!root.asset.comments) { + return false; + } + return findComment(root.asset.comments.nodes, callback); +} + +const ascending = (a, b) => { + const dateA = new Date(a.created_at); + const dateB = new Date(b.created_at); + if (dateA < dateB) { return -1; } + if (dateA > dateB) { return 1; } + return 0; +}; + +function findAndInsertFetchedComments(parent, comments, parent_id) { + const isAsset = parent.__typename === 'Asset'; + const connectionField = isAsset ? 'comments' : 'replies'; + if (!parent_id && connectionField === 'comments' || parent.id === parent_id) { + return update(parent, { + [connectionField]: { + hasNextPage: {$set: comments.hasNextPage}, + endCursor: {$set: comments.endCursor}, + nodes: {$apply: (nodes) => { + if (isAsset) { + return nodes.concat(comments.nodes); + } + return nodes + .concat(comments.nodes.filter( + (comment) => !nodes.some((node) => node.id === comment.id) + )) + .sort(ascending); + }}, }, }); } - return update(root, { - asset: {$apply: (asset) => findAndRemoveComment(asset, id)}, + + const connection = parent[connectionField]; + if (!connection) { + return parent; + } + return update(parent, { + [connectionField]: { + nodes: { + $apply: (nodes) => + nodes.map((node) => findAndInsertFetchedComments(node, comments, parent_id)) + }, + }, }); } + +export function insertFetchedCommentsIntoEmbedQuery(root, comments, parent_id) { + return applyToCommentsOrigin(root, (origin) => findAndInsertFetchedComments(origin, comments, parent_id)); +} diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index 06d52649a..821399488 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -2,30 +2,30 @@ import React from 'react'; import {render} from 'react-dom'; import {ApolloProvider} from 'react-apollo'; -import {client} from 'coral-framework/services/client'; import {checkLogin} from 'coral-framework/actions/auth'; import './graphql'; import {addExternalConfig} from 'coral-embed-stream/src/actions/config'; - -import reducers from './reducers'; import {getStore, injectReducers} from 'coral-framework/services/store'; +import {getClient} from 'coral-framework/services/client'; import AppRouter from './AppRouter'; import {pym} from 'coral-framework'; -import {loadPluginsTranslations} from 'coral-framework/helpers/plugins'; +import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins'; +import reducers from './reducers'; const store = getStore(); +const client = getClient(); loadPluginsTranslations(); +injectPluginsReducers(); injectReducers(reducers); // Don't run this in the popup. if (!window.opener) { - store.dispatch(checkLogin()); - pym.sendMessage('getConfig'); pym.onMessage('config', (config) => { store.dispatch(addExternalConfig(JSON.parse(config))); + store.dispatch(checkLogin()); }); } diff --git a/client/coral-embed-stream/src/reducers/embed.js b/client/coral-embed-stream/src/reducers/embed.js index 0fd661543..06073c54e 100644 --- a/client/coral-embed-stream/src/reducers/embed.js +++ b/client/coral-embed-stream/src/reducers/embed.js @@ -3,6 +3,8 @@ import * as actions from '../constants/embed'; const initialState = { activeTab: 'stream', previousTab: '', + refetching: false, + refetchRequestId: 0, }; export default function stream(state = initialState, action) { @@ -13,6 +15,23 @@ export default function stream(state = initialState, action) { activeTab: action.tab, previousTab: state.activeTab, }; + case 'APOLLO_QUERY_INIT': + if (action.queryString.indexOf('query CoralEmbedStream_Embed(') >= 0) { + return { + ...state, + refetching: action.isRefetch ? true : state.refetching, + refetchRequestId: action.isRefetch ? action.requestId : state.refetchRequestId, + }; + } + return state; + case 'APOLLO_QUERY_RESULT': + if (action.operationName === 'CoralEmbedStream_Embed') { + return { + ...state, + refetching: action.requestId === state.refetchRequestId ? false : state.refetching, + }; + } + return state; default: return state; } diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index 570a63aed..65e77ad1d 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -147,6 +147,7 @@ function configurePymParent(pymParent, opts) { * @param {String} [opts.title] - Title of Stream (rendered in iframe) * @param {String} [opts.asset_url] - Asset URL * @param {String} [opts.asset_id] - Asset ID + * @param {String} [opts.auth_token] - (optional) A jwt representing the session */ Talk.render = function(el, opts) { if (!el) { diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index 5568b7517..af84188c1 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -5,14 +5,9 @@ import merge from 'lodash/merge'; import plugins from 'pluginsConfig'; import flatten from 'lodash/flatten'; import flattenDeep from 'lodash/flattenDeep'; -import {loadTranslations} from 'coral-framework/services/i18n'; import {getDefinitionName, mergeDocuments} from 'coral-framework/utils'; - -export const pluginReducers = merge( - ...plugins - .filter((o) => o.module.reducer) - .map((o) => ({[o.plugin] : o.module.reducer})) -); +import {loadTranslations} from 'coral-framework/services/i18n'; +import {injectReducers} from 'coral-framework/services/store'; /** * Returns React Elements for given slot. @@ -98,3 +93,12 @@ function getTranslations() { export function loadPluginsTranslations() { getTranslations().forEach((t) => loadTranslations(t)); } + +export function injectPluginsReducers() { + const reducers = merge( + ...plugins + .filter((o) => o.module.reducer) + .map((o) => ({[o.plugin] : o.module.reducer})) + ); + injectReducers(reducers); +} diff --git a/client/coral-framework/helpers/request.js b/client/coral-framework/helpers/request.js index 0e15003e1..57e3a6cde 100644 --- a/client/coral-framework/helpers/request.js +++ b/client/coral-framework/helpers/request.js @@ -1,6 +1,30 @@ import bowser from 'bowser'; import * as Storage from './storage'; import merge from 'lodash/merge'; +import {getStore} from 'coral-framework/services/store'; + +/** + * getAuthToken returns the active auth token or null + * Note: this method does not have access to the cookie based token used by + * browsers that don't allow us to use cross domain iframe local storage. + * @return {string|null} + */ +export const getAuthToken = () => { + let state = getStore().getState(); + + if (state.config.auth_token) { + + // if an auth_token exists in config, use it. + return state.config.auth_token; + + } else if (!bowser.safari && !bowser.ios) { + + // Use local storage auth tokens where there's a stable api. + return Storage.getItem('token'); + } + + return null; +}; const buildOptions = (inputOptions = {}) => { const defaultOptions = { @@ -14,12 +38,10 @@ const buildOptions = (inputOptions = {}) => { let options = merge({}, defaultOptions, inputOptions); - if (!bowser.safari && !bowser.ios) { - let authorization = Storage.getItem('token'); - - if (authorization) { - options.headers.Authorization = `Bearer ${authorization}`; - } + // Apply authToken header + let authToken = getAuthToken(); + if (authToken !== null) { + options.headers.Authorization = `Bearer ${authToken}`; } if (options.method.toLowerCase() !== 'get') { diff --git a/client/coral-framework/reducers/index.js b/client/coral-framework/reducers/index.js index d5b66bbae..8928cae93 100644 --- a/client/coral-framework/reducers/index.js +++ b/client/coral-framework/reducers/index.js @@ -2,12 +2,10 @@ import auth from './auth'; import user from './user'; import asset from './asset'; import {reducer as commentBox} from '../../coral-plugin-commentbox'; -import {pluginReducers} from '../helpers/plugins'; export default { auth, user, asset, commentBox, - ...pluginReducers }; diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js index c77bc9848..6095e17ab 100644 --- a/client/coral-framework/services/client.js +++ b/client/coral-framework/services/client.js @@ -1,28 +1,36 @@ import ApolloClient, {addTypename} from 'apollo-client'; import {networkInterface} from './transport'; +import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws'; -// import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws'; +let client; -// 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, -// ); -export const client = new ApolloClient({ - connectToDevTools: true, - addTypename: true, - queryTransformer: addTypename, - dataIdFromObject: (result) => { - if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle - return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle - } - return null; - }, - networkInterface -}); +export function getClient() { + if (client) { + return client; + } -export default client; + const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; + const wsClient = new SubscriptionClient(`${protocol}://${location.host}/api/v1/live`, { + reconnect: true + }); + const networkInterfaceWithSubscriptions = addGraphQLSubscriptions( + networkInterface, + wsClient, + ); + + client = new ApolloClient({ + connectToDevTools: true, + addTypename: true, + queryTransformer: addTypename, + dataIdFromObject: (result) => { + if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle + return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle + } + return null; + }, + networkInterface: networkInterfaceWithSubscriptions, + }); + + return client; +} diff --git a/client/coral-framework/services/store.js b/client/coral-framework/services/store.js index 0530d8c28..4d6dafe20 100644 --- a/client/coral-framework/services/store.js +++ b/client/coral-framework/services/store.js @@ -1,28 +1,7 @@ import {createStore, combineReducers, applyMiddleware, compose} from 'redux'; import thunk from 'redux-thunk'; import mainReducer from '../reducers'; -import {client} from './client'; - -const apolloErrorReporter = () => (next) => (action) => { - if (action.type === 'APOLLO_QUERY_ERROR') { - console.error(action.error); - } - return next(action); -}; - -const middlewares = [ - applyMiddleware( - client.middleware(), - thunk, - apolloErrorReporter, - ), -]; - -if (window.devToolsExtension) { - - // we can't have the last argument of compose() be undefined - middlewares.push(window.devToolsExtension()); -} +import {getClient} from './client'; export function injectReducers(reducers) { const store = getStore(); @@ -35,9 +14,30 @@ export function getStore() { return window.coralStore; } + const apolloErrorReporter = () => (next) => (action) => { + if (action.type === 'APOLLO_QUERY_ERROR') { + console.error(action.error); + } + return next(action); + }; + + const middlewares = [ + applyMiddleware( + getClient().middleware(), + thunk, + apolloErrorReporter, + ), + ]; + + if (window.devToolsExtension) { + + // we can't have the last argument of compose() be undefined + middlewares.push(window.devToolsExtension()); + } + const coralReducers = { ...mainReducer, - apollo: client.reducer() + apollo: getClient().reducer() }; window.coralStore = createStore( diff --git a/client/coral-framework/services/subscriptions.js b/client/coral-framework/services/subscriptions.js deleted file mode 100644 index 818a6fb33..000000000 --- a/client/coral-framework/services/subscriptions.js +++ /dev/null @@ -1,16 +0,0 @@ -import {print} from 'graphql-tag/printer'; - -// quick way to add the subscribe and unsubscribe functions to the network interface -const addGraphQLSubscriptions = (networkInterface, wsClient) => { - return Object.assign(networkInterface, { - subscribe: (request, handler) => wsClient.subscribe({ - query: print(request.query), - variables: request.variables, - }, handler), - unsubscribe: (id) => { - wsClient.unsubscribe(id); - }, - }); -}; - -export default addGraphQLSubscriptions; diff --git a/client/coral-framework/services/transport.js b/client/coral-framework/services/transport.js index 0c430a021..f11c20888 100644 --- a/client/coral-framework/services/transport.js +++ b/client/coral-framework/services/transport.js @@ -1,6 +1,5 @@ import {createNetworkInterface} from 'apollo-client'; -import * as Storage from '../helpers/storage'; -import bowser from 'bowser'; +import {getAuthToken} from '../helpers/request'; //============================================================================== // NETWORK INTERFACE @@ -23,8 +22,9 @@ networkInterface.use([{ req.options.headers = {}; // Create the header object if needed. } - if (!bowser.safari && !bowser.ios) { - req.options.headers['authorization'] = `Bearer ${Storage.getItem('token')}`; + let authToken = getAuthToken(); + if (authToken) { + req.options.headers['authorization'] = `Bearer ${authToken}`; } next(); diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index 3cd48d2b0..5105667ed 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -36,18 +36,10 @@ class CommentBox extends React.Component { } }; } - static get defaultProps() { - return { - setCommentCountCache: () => {} - }; - } postComment = ({body}) => { const { commentPostedHandler, postComment, - setCommentCountCache, - commentCountCache, - isReply, assetId, parentId, addNotification, @@ -60,8 +52,6 @@ class CommentBox extends React.Component { ...this.props.commentBox }; - !isReply && setCommentCountCache(commentCountCache + 1); - // Execute preSubmit Hooks this.state.hooks.preSubmit.forEach((hook) => hook()); @@ -74,19 +64,12 @@ class CommentBox extends React.Component { notifyForNewCommentStatus(addNotification, postedComment.status); - if (postedComment.status === 'REJECTED') { - !isReply && setCommentCountCache(commentCountCache); - } else if (postedComment.status === 'PREMOD') { - !isReply && setCommentCountCache(commentCountCache); - } - if (commentPostedHandler) { commentPostedHandler(); } }) .catch((err) => { console.error(err); - !isReply && setCommentCountCache(commentCountCache); }); this.setState({postedCount: this.state.postedCount + 1}); @@ -190,7 +173,6 @@ CommentBox.propTypes = { authorId: PropTypes.string.isRequired, isReply: PropTypes.bool.isRequired, canPost: PropTypes.bool, - setCommentCountCache: PropTypes.func, }; const mapStateToProps = ({commentBox}) => ({commentBox}); diff --git a/client/coral-ui/components/Tab.js b/client/coral-ui/components/Tab.js index af04bec9c..cc2ee82d7 100644 --- a/client/coral-ui/components/Tab.js +++ b/client/coral-ui/components/Tab.js @@ -1,13 +1,12 @@ import React from 'react'; import styles from './Tab.css'; -export default ({children, tabId, active, onTabClick, cStyle = 'base'}) => ( +export default ({children, tabId, active, onTabClick, cStyle = 'base', ...props}) => (
  • onTabClick(tabId)} > {children}
  • ); - diff --git a/client/coral-ui/components/TabBar.js b/client/coral-ui/components/TabBar.js index d03a5d5b1..7809dee11 100644 --- a/client/coral-ui/components/TabBar.js +++ b/client/coral-ui/components/TabBar.js @@ -17,13 +17,13 @@ class TabBar extends React.Component { const {children, activeTab, cStyle = 'base'} = this.props; return (
    -
      +
        {React.Children.toArray(children) .filter((child) => !child.props.restricted) .map((child, tabId) => React.cloneElement(child, { tabId, - active: tabId === activeTab, + active: child.props.id === activeTab, onTabClick: this.handleClickTab, cStyle }) diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 7d234c755..88c391078 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -340,6 +340,12 @@ const edit = async (context, {id, asset_id, edit: {body}}) => { // Execute the edit. const comment = await CommentsService.edit(id, context.user.id, {body, status}); + if (context.pubsub) { + + // Publish the edited comment via the subscription. + context.pubsub.publish('commentEdited', comment); + } + return comment; }; diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index 2e8412169..33ec0ef60 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -49,7 +49,7 @@ const Comment = { }, async editing(comment, _, {loaders: {Settings}}) { const settings = await Settings.load(); - const editableUntil = new Date(Number(comment.created_at) + settings.editCommentWindowLength); + const editableUntil = new Date(Number(new Date(comment.created_at)) + settings.editCommentWindowLength); return { edited: comment.edited, editableUntil: editableUntil diff --git a/graph/resolvers/subscription.js b/graph/resolvers/subscription.js index b3f5e655c..a593c1eb6 100644 --- a/graph/resolvers/subscription.js +++ b/graph/resolvers/subscription.js @@ -1,6 +1,9 @@ const Subscription = { commentAdded(comment) { return comment; + }, + commentEdited(comment) { + return comment; } }; diff --git a/graph/subscriptions.js b/graph/subscriptions.js index 2eb676cc4..0442c237d 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -25,6 +25,11 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu filter: (comment) => comment.asset_id === args.asset_id }, }), + commentEdited: (options, args) => ({ + commentEdited: { + filter: (comment) => comment.asset_id === args.asset_id + }, + }), }); /** diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index aed610ad7..187e5bba5 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -883,6 +883,7 @@ type RootMutation { type Subscription { commentAdded(asset_id: ID!): Comment + commentEdited(asset_id: ID!): Comment } ################################################################################ diff --git a/locales/en.yml b/locales/en.yml index c99192ed5..403befecc 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -158,8 +158,8 @@ en: edit_window_expired_close: "Close" edit_window_timer_prefix: "Edit Window: " second: "second" - secondsPlural: "seconds" - unexpectedError: "Unexpected error while saving changes. Sorry!" + seconds_plural: "seconds" + unexpected_error: "Unexpected error while saving changes. Sorry!" email: confirm: has_been_requested: "A email confirmation has been requested for the following account:" @@ -202,7 +202,7 @@ en: flag_reason: "Reason for reporting (Optional)" flag_username: "Report username" framework: - banned_account_msg: "Your account is currently suspended. This means that you cannot Like Report or write comments. Please contact us if you have any questions." + banned_account_msg: "Your account is currently banned. This means that you cannot Like, Report, or write comments. Please contact us if you have any questions." because_you_ignored: "Because you ignored the following commenters, their comments are hidden." comment: comment comment_is_ignored: "This comment is hidden because you ignored this user." @@ -224,6 +224,7 @@ en: success_bio_update: "Your biography has been updated" success_name_update: "Your username has been updated" success_update_settings: "The changes you have made have been applied to the comment stream on this article" + view_all_replies_unknown_number: "view all replies" view_all_replies: "view {0} replies" view_all_replies_initial: "view all {0} replies" view_more_comments: "view more comments" @@ -322,7 +323,7 @@ en: bio: bio cancel: "Cancel" days: "{0} days" - description_0: "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}." + description_0: "Would you like to temporarily suspend this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}." description_1: "Suspending this user will temporarily disable their account and hide all of their comments on the site." description_notify: "Suspending this user will temporarily disable their account and hide all of their comments on the site." description_reject: "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}." diff --git a/locales/es.yml b/locales/es.yml index 9e7a8bcba..0802a7604 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -223,6 +223,7 @@ es: success_bio_update: "Tu biografia fue actualizada" success_name_update: "Tu nombre de usuario ha sido actualizado" success_update_settings: "La configuración de este articulo fue actualizada" + view_all_replies_unknown_number: "ver todas las respuestas" view_all_replies: "ver {0} respuestas" view_all_replies_initial: "ver todas las {0} respuestas" view_more_comments: "Ver más comentarios" diff --git a/models/comment.js b/models/comment.js index 27a2bfae3..1ffeb3fd6 100644 --- a/models/comment.js +++ b/models/comment.js @@ -104,7 +104,10 @@ const CommentSchema = new Schema({ timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' - } + }, + toJSON: { + virtuals: true, + }, }); CommentSchema.virtual('edited').get(function() { diff --git a/package.json b/package.json index fe87f17f5..766aff70c 100644 --- a/package.json +++ b/package.json @@ -92,11 +92,11 @@ "minimist": "^1.2.0", "mongoose": "^4.9.8", "morgan": "^1.8.1", + "ms": "^2.0.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-jwt": "^2.2.1", "passport-local": "^1.0.0", @@ -104,6 +104,7 @@ "react-apollo": "^1.1.0", "react-recaptcha": "^2.2.6", "react-toastify": "^1.5.0", + "react-transition-group": "^1.1.3", "recompose": "^0.23.1", "redis": "^2.7.1", "resolve": "^1.3.2", diff --git a/plugins/coral-plugin-auth/client/components/SignInContainer.js b/plugins/coral-plugin-auth/client/components/SignInContainer.js index 6f3c460bb..6950f4124 100644 --- a/plugins/coral-plugin-auth/client/components/SignInContainer.js +++ b/plugins/coral-plugin-auth/client/components/SignInContainer.js @@ -18,7 +18,6 @@ import { facebookCallback, invalidForm, validForm, - checkLogin } from 'coral-framework/actions/auth'; class SignInContainer extends React.Component { @@ -38,10 +37,6 @@ class SignInContainer extends React.Component { }; } - componentWillMount() { - this.props.checkLogin(); - } - componentDidMount() { window.addEventListener('storage', this.handleAuth); @@ -187,7 +182,6 @@ const mapStateToProps = (state) => ({ const mapDispatchToProps = (dispatch) => bindActionCreators( { - checkLogin, facebookCallback, fetchSignUp, fetchSignIn, diff --git a/plugins/coral-plugin-auth/client/components/UserBox.js b/plugins/coral-plugin-auth/client/components/UserBox.js index 47608c0e3..5b79b664a 100644 --- a/plugins/coral-plugin-auth/client/components/UserBox.js +++ b/plugins/coral-plugin-auth/client/components/UserBox.js @@ -9,10 +9,10 @@ const UserBox = ({loggedIn, user, logout, onShowProfile}) => (
        { loggedIn ? ( -
        +
        {t('sign_in.logged_in_as')} {user.username}. {t('sign_in.not_you')} - logout()}> + logout()}> {t('sign_in.logout')}
        diff --git a/plugins/coral-plugin-respect/client/components/RespectButton.js b/plugins/coral-plugin-respect/client/components/RespectButton.js index 9bce367f4..58bb35e9c 100644 --- a/plugins/coral-plugin-respect/client/components/RespectButton.js +++ b/plugins/coral-plugin-respect/client/components/RespectButton.js @@ -7,6 +7,8 @@ import cn from 'classnames'; import {getMyActionSummary, getTotalActionCount} from 'coral-framework/utils'; +const name = 'coral-plugin-respect'; + class RespectButton extends Component { handleClick = () => { @@ -47,13 +49,13 @@ class RespectButton extends Component { let count = getTotalActionCount('RespectActionSummary', comment); return ( -
        +
        ); diff --git a/plugins/coral-plugin-viewing-options/client/containers/ViewingOptions.js b/plugins/coral-plugin-viewing-options/client/containers/ViewingOptions.js index 0e0358b86..2d161218b 100644 --- a/plugins/coral-plugin-viewing-options/client/containers/ViewingOptions.js +++ b/plugins/coral-plugin-viewing-options/client/containers/ViewingOptions.js @@ -1,4 +1,3 @@ -import React from 'react'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import ViewingOptions from '../components/ViewingOptions'; diff --git a/services/passport.js b/services/passport.js index 4f5a8fb8d..d42dfa295 100644 --- a/services/passport.js +++ b/services/passport.js @@ -10,6 +10,7 @@ const uuid = require('uuid'); const debug = require('debug')('talk:passport'); const {createClient} = require('./redis'); const bowser = require('bowser'); +const ms = require('ms'); // Create a redis client to use for authentication. const client = createClient(); @@ -39,7 +40,8 @@ const SetTokenForSafari = (req, res, token) => { if (browser.ios || browser.safari) { res.cookie('authorization', token, { httpOnly: true, - expires: new Date(Date.now() + 900000) + secure: process.env.NODE_ENV === 'production', + expires: new Date(Date.now() + ms(JWT_EXPIRY)) }); } }; @@ -172,6 +174,7 @@ const CheckBlacklisted = (jwt) => new Promise((resolve, reject) => { }); }); +const jwt = require('jsonwebtoken'); const JwtStrategy = require('passport-jwt').Strategy; const ExtractJwt = require('passport-jwt').ExtractJwt; @@ -185,6 +188,19 @@ let cookieExtractor = function(req) { return token; }; +// Override the JwtVerifier method on the JwtStrategy so we can pack the +// original token into the payload. +JwtStrategy.JwtVerifier = (token, secretOrKey, options, callback) => { + return jwt.verify(token, secretOrKey, options, (err, jwt) => { + if (err) { + return callback(err); + } + + // Attach the original token onto the payload. + return callback(false, {token, jwt}); + }); +}; + // Extract the JWT from the 'Authorization' header with the 'Bearer' scheme. passport.use(new JwtStrategy({ @@ -207,10 +223,10 @@ passport.use(new JwtStrategy({ // Enable only the HS256 algorithm. algorithms: ['HS256'], - // Pass the request objecto back to the callback so we can attach the JWT to + // Pass the request object back to the callback so we can attach the JWT to // it. passReqToCallback: true -}, async (req, jwt, done) => { +}, async (req, {token, jwt}, done) => { // Load the user from the environment, because we just got a user from the // header. @@ -219,7 +235,9 @@ passport.use(new JwtStrategy({ // Check to see if the token has been revoked await CheckBlacklisted(jwt); - let user = await UsersService.findById(jwt.sub); + // Try to get the user from the database or crack it from the token and + // plugin integrations. + let user = await UsersService.findOrCreateByIDToken(jwt.sub, {token, jwt}); // Attach the JWT to the request. req.jwt = jwt; diff --git a/services/users.js b/services/users.js index 09ddd8ce9..9bca10ea5 100644 --- a/services/users.js +++ b/services/users.js @@ -9,6 +9,7 @@ const { JWT_SECRET, ROOT_URL } = require('../config'); +const debug = require('debug')('talk:services:users'); const redis = require('./redis'); const redisClient = redis.createClient(); @@ -526,6 +527,29 @@ module.exports = class UsersService { return UserModel.findOne({id}); } + /** + * + * @param {String} id the id of the current user + * @param {Object} token a jwt token used to sign in the user + */ + static async findOrCreateByIDToken(id, token) { + + // Try to get the user. + let user = await UserModel.findOne({ + id + }); + + // If the user was not found, try to look it up. + if (user === null) { + + // If the user wasn't found, it will return null and the variable will be + // unchanged. + user = await lookupUserNotFound(token); + } + + return user; + } + /** * Finds users in an array of ids. * @param {Array} ids array of user identifiers (uuid) @@ -903,3 +927,30 @@ module.exports = class UsersService { }); } }; + +// Extract all the tokenUserNotFound plugins so we can integrate with other +// providers. +let tokenUserNotFoundHooks = null; + +// Provide a function that can loop over the hooks and search for a provider +// can crack the token to a user. +const lookupUserNotFound = async (token) => { + if (!Array.isArray(tokenUserNotFoundHooks)) { + tokenUserNotFoundHooks = require('./plugins') + .get('server', 'tokenUserNotFound') + .map(({plugin, tokenUserNotFound}) => { + debug(`added plugin '${plugin.name}' to tokenUserNotFound hooks`); + + return tokenUserNotFound; + }); + } + + for (let hook of tokenUserNotFoundHooks) { + let user = await hook(token); + if (user !== null && typeof user !== 'undefined') { + return user; + } + } + + return null; +}; diff --git a/views/article.ejs b/views/article.ejs index bc29467f1..c216f158d 100644 --- a/views/article.ejs +++ b/views/article.ejs @@ -29,6 +29,7 @@ talk: '/', asset_url: '<%= asset_url ? asset_url : '' %>', asset_id: '<%= asset_id ? asset_id : '' %>', + auth_token: '', plugin_config: { test: 'data', debug: false diff --git a/yarn.lock b/yarn.lock index 9855d91e8..4242faec0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5365,6 +5365,10 @@ ms@0.7.3, ms@^0.7.1: version "0.7.3" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.3.tgz#708155a5e44e33f5fd0fc53e81d0d40a91be1fff" +ms@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + muri@1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/muri/-/muri-1.2.1.tgz#ec7ea5ce6ca6a523eb1ab35bacda5fa816c9aa3c" @@ -5861,10 +5865,6 @@ parse-asn1@^5.0.0: evp_bytestokey "^1.0.0" pbkdf2 "^3.0.3" -parse-duration@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/parse-duration/-/parse-duration-0.1.1.tgz#13114ddc9891c1ecd280036244554de43647a226" - parse-glob@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" @@ -6909,7 +6909,7 @@ react-toastify@^1.5.0: prop-types "^15.5.8" react-transition-group "^1.1.2" -react-transition-group@^1.1.2: +react-transition-group@^1.1.2, react-transition-group@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-1.1.3.tgz#5e02cf6e44a863314ff3c68a0c826c2d9d70b221" dependencies: