diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js index 9d679473e..8371cec45 100644 --- a/client/coral-embed-stream/src/Comment.js +++ b/client/coral-embed-stream/src/Comment.js @@ -177,7 +177,7 @@ class Comment extends React.Component { comment.replies &&
comment.replies.length} diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js index a7c4dcb38..5e979d243 100644 --- a/client/coral-embed-stream/src/Embed.js +++ b/client/coral-embed-stream/src/Embed.js @@ -12,6 +12,7 @@ const {fetchAssetSuccess} = assetActions; import {queryStream} from 'coral-framework/graphql/queries'; import {postComment, postFlag, postLike, deleteAction} from 'coral-framework/graphql/mutations'; import {editName} from 'coral-framework/actions/user'; +import {updateCountCache} from 'coral-framework/actions/asset'; import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework'; import Stream from './Stream'; @@ -28,6 +29,7 @@ import SettingsContainer from 'coral-settings/containers/SettingsContainer'; import RestrictedContent from 'coral-framework/components/RestrictedContent'; import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer'; import LoadMore from './LoadMore'; +import NewCount from './NewCount'; class Embed extends Component { @@ -82,7 +84,7 @@ class Embed extends Component { render () { const {activeTab} = this.state; - const {closedAt} = this.props.asset; + const {closedAt, countCache = {}} = this.props.asset; const {loading, asset, refetch} = this.props.data; const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth; @@ -98,6 +100,11 @@ class Embed extends Component { 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(); + return (
@@ -132,6 +139,8 @@ class Embed extends Component { postItem={this.props.postItem} appendItemArray={this.props.appendItemArray} updateItem={this.props.updateItem} + updateCountCache={this.props.updateCountCache} + countCache={countCache[asset.id]} assetId={asset.id} premod={asset.settings.moderation} isReply={false} @@ -147,6 +156,14 @@ class Embed extends Component { } {!loggedIn && } {loggedIn && user && } + ({ clearNotification: () => dispatch(clearNotification()), editName: (username) => dispatch(editName(username)), showSignInDialog: (offset) => dispatch(showSignInDialog(offset)), + updateCountCache: (id, count) => dispatch(updateCountCache(id, count)), logout: () => dispatch(logout()), dispatch: d => dispatch(d) }); diff --git a/client/coral-embed-stream/src/LoadMore.js b/client/coral-embed-stream/src/LoadMore.js index 89708272a..e33828bc1 100644 --- a/client/coral-embed-stream/src/LoadMore.js +++ b/client/coral-embed-stream/src/LoadMore.js @@ -12,7 +12,7 @@ const loadMoreComments = (assetId, comments, loadMore, parentId) => { } const cursor = parentId - ? comments[1].created_at + ? comments[0].created_at : comments[comments.length - 1].created_at; loadMore({ diff --git a/client/coral-embed-stream/src/NewCount.js b/client/coral-embed-stream/src/NewCount.js new file mode 100644 index 000000000..429ecc113 --- /dev/null +++ b/client/coral-embed-stream/src/NewCount.js @@ -0,0 +1,40 @@ +import React, {PropTypes} from 'react'; +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) => { + e.preventDefault(); + updateCountCache(assetId, commentCount); + loadMore({ + limit: 500, + cursor: firstCommentDate, + assetId, + sort: 'CHRONOLOGICAL' + }, true); +}; + +const NewCount = (props) => { + const newComments = props.commentCount - props.countCache; + + return
+ { + props.countCache && newComments > 0 && + + } +
; +}; + +NewCount.propTypes = { + commentCount: PropTypes.number.isRequired, + countCache: PropTypes.number, + loadMore: PropTypes.func.isRequired, + assetId: PropTypes.string.isRequired, + firstCommentDate: PropTypes.string.isRequired +}; + +export default NewCount; diff --git a/client/coral-embed-stream/src/Stream.js b/client/coral-embed-stream/src/Stream.js index 2f0f4b01b..89a637ceb 100644 --- a/client/coral-embed-stream/src/Stream.js +++ b/client/coral-embed-stream/src/Stream.js @@ -1,5 +1,6 @@ import React, {PropTypes} from 'react'; import Comment from './Comment'; +import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments'; class Stream extends React.Component { @@ -17,10 +18,30 @@ class Stream extends React.Component { constructor(props) { super(props); - this.state = {activeReplyBox: ''}; + this.state = {activeReplyBox: '', countPoll: null}; this.setActiveReplyBox = this.setActiveReplyBox.bind(this); } + componentDidMount() { + const {asset, getCounts, updateCountCache} = this.props; + + updateCountCache(asset.id, asset.commentCount); + + // Note: Apollo's built-in polling doesn't work with fetchMore queries, so a + // setInterval is being used instead. + this.setState({ + countPoll: setInterval(() => getCounts({ + asset_id: asset.id, + limit: asset.comments.length, + sort: 'REVERSE_CHRONOLOGICAL' + }), NEW_COMMENT_COUNT_POLL_INTERVAL), + }); + } + + componentWillUnmount() { + clearInterval(this.state.countPoll); + } + setActiveReplyBox (reactKey) { if (!this.props.currentUser) { const offset = document.getElementById(`c_${reactKey}`).getBoundingClientRect().top - 75; diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 9d6723082..798da98af 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -356,18 +356,26 @@ button.coral-load-more { text-align: center; color: #FFF; background-color: #2376D8; + cursor: pointer; } button.coral-load-more:hover { background-color: #4399FF; } -.coral-load-more-replies { +.coral-load-more-replies, .coral-new-comments { width: 100%; display: flex; justify-content: center; + cursor: pointer; } -.coral-load-more-replies button.coral-load-more { +.coral-new-comments { + position: relative; + top: 1.8em; + z-index: 100; +} + +.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{ width: initial; } diff --git a/client/coral-framework/actions/asset.js b/client/coral-framework/actions/asset.js index d5db289c3..02e72ea98 100644 --- a/client/coral-framework/actions/asset.js +++ b/client/coral-framework/actions/asset.js @@ -38,6 +38,7 @@ 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') { diff --git a/client/coral-framework/constants/asset.js b/client/coral-framework/constants/asset.js index 40f746706..234095d9d 100644 --- a/client/coral-framework/constants/asset.js +++ b/client/coral-framework/constants/asset.js @@ -8,3 +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'; diff --git a/client/coral-framework/constants/comments.js b/client/coral-framework/constants/comments.js index 17ea2223e..28db9cdf9 100644 --- a/client/coral-framework/constants/comments.js +++ b/client/coral-framework/constants/comments.js @@ -1 +1,2 @@ export const ADDTL_COMMENTS_ON_LOAD_MORE = 10; +export const NEW_COMMENT_COUNT_POLL_INTERVAL = 20000; diff --git a/client/coral-framework/graphql/queries/index.js b/client/coral-framework/graphql/queries/index.js index 09b3159d0..c993dfb6a 100644 --- a/client/coral-framework/graphql/queries/index.js +++ b/client/coral-framework/graphql/queries/index.js @@ -1,6 +1,7 @@ import {graphql} from 'react-apollo'; 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'; function getQueryVariable(variable) { @@ -17,6 +18,72 @@ function getQueryVariable(variable) { return 'http://localhost/default/stream'; } +export const getCounts = (data) => ({asset_id, limit, sort}) => { + return data.fetchMore({ + query: GET_COUNTS, + variables: { + asset_id, + limit, + sort + }, + updateQuery: (oldData, {fetchMoreResult:{data}}) => { + + return { + ...oldData, + asset: { + ...oldData.asset, + commentCount: data.asset.commentCount + } + }; + } + }); +}; + +export const loadMore = (data) => ({limit, cursor, parent_id, asset_id, sort}, newComments) => { + return data.fetchMore({ + query: LOAD_MORE, + variables: { + limit, + cursor, + parent_id, + asset_id, + sort + }, + updateQuery: (oldData, {fetchMoreResult:{data:{new_top_level_comments}}}) => { + + let updatedAsset; + + if (parent_id) { + + // If loading more replies + updatedAsset = { + ...oldData, + asset: { + ...oldData.asset, + comments: oldData.asset.comments.map((comment) => + comment.id === parent_id + ? {...comment, replies: [...comment.replies, ...new_top_level_comments]} + : 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 queryStream = graphql(STREAM_QUERY, { options: () => ({ variables: { @@ -25,40 +92,8 @@ export const queryStream = graphql(STREAM_QUERY, { }), props: ({data}) => ({ data, - loadMore: ({limit, cursor, parent_id, asset_id, sort}) => { - return data.fetchMore({ - query: LOAD_MORE, - variables: { - limit, - cursor, - parent_id, - asset_id, - sort - }, - updateQuery: (oldData, {fetchMoreResult:{data:{new_top_level_comments}}}) => - - // If loading more replies - parent_id ? { - ...oldData, - asset: { - ...oldData.asset, - comments: oldData.asset.comments.map((comment) => - comment.id === parent_id - ? {...comment, replies: [...comment.replies, ...new_top_level_comments]} - : comment) - } - } - - // If loading more top-level comments - : { - ...oldData, - asset: { - ...oldData.asset, - comments: [...oldData.asset.comments, ...new_top_level_comments] - } - } - }); - } + loadMore: loadMore(data), + getCounts: getCounts(data), }) }); diff --git a/client/coral-framework/reducers/asset.js b/client/coral-framework/reducers/asset.js index f9d0a55e3..067edfc5b 100644 --- a/client/coral-framework/reducers/asset.js +++ b/client/coral-framework/reducers/asset.js @@ -19,6 +19,9 @@ 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/translations.json b/client/coral-framework/translations.json index 2884178b6..c817925eb 100644 --- a/client/coral-framework/translations.json +++ b/client/coral-framework/translations.json @@ -11,6 +11,9 @@ "button": "Submit", "error": "Usernames can contain letters, numbers and _ only" }, + "newCount": "View {0} more {1}", + "comment": "comment", + "comments": "comments", "error": { "emailNotVerified": "Email address {0} not verified.", "email": "Not a valid E-Mail", @@ -39,6 +42,9 @@ "bannedAccountMsg": "Tu cuenta se encuentra suspendida. Esto significa que no puedes dar Like, Marcar o escribir commentarios. Por favor, contacta moderator@fakeurl for more information", "editNameMsg": "", "loadMore": "Ver más", + "newCount": "Ver {0} {1} más", + "comment": "commentario", + "comments": "commentarios", "error": { "emailNotVerified": "Dirección de correo electrónico {0} no verificada.", "email": "No es un email válido", diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index 344ade150..3d895df07 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -29,6 +29,8 @@ class CommentBox extends Component { commentPostedHandler, postItem, assetId, + updateCountCache, + countCache, parentId, addNotification, authorId @@ -44,14 +46,17 @@ class CommentBox extends Component { if (this.props.charCount && this.state.body.length > this.props.charCount) { return; } + updateCountCache(assetId, countCache + 1); postItem(comment, 'comments') .then(({data}) => { const postedComment = data.createComment.comment; if (postedComment.status === 'REJECTED') { addNotification('error', lang.t('comment-post-banned-word')); + updateCountCache(assetId, countCache); } else if (postedComment.status === 'PREMOD') { addNotification('success', lang.t('comment-post-notif-premod')); + updateCountCache(assetId, countCache); } else { addNotification('success', 'Your comment has been posted.'); } diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 72a54a0cd..7db4dbad8 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -448,7 +448,7 @@ type RootQuery { # Comments returned based on a query. comments(query: CommentsQuery!): [Comment] - # Returne the count of comments satisfied by the query. Note that this edge is + # Return the count of comments satisfied by the query. Note that this edge is # expensive as it is not batched. Requires the `ADMIN` role. commentCount(query: CommentCountQuery!): Int diff --git a/test/client/coral-framework/reducers/assetReducer.spec.js b/test/client/coral-framework/reducers/assetReducer.spec.js new file mode 100644 index 000000000..c54f51737 --- /dev/null +++ b/test/client/coral-framework/reducers/assetReducer.spec.js @@ -0,0 +1,19 @@ +import {Map} from 'immutable'; +import {expect} from 'chai'; +import assetReducer from '../../../../client/coral-framework/reducers/asset'; +import * as actions from '../../../../client/coral-framework/constants/asset'; + +describe ('coral-embed-stream assetReducer', () => { + describe('UPDATE_COUNT_CACHE', () => { + it('should update the count cache', () => { + const action = { + type: actions.UPDATE_COUNT_CACHE, + id: '123', + count: 456 + }; + const store = new Map({}); + const result = assetReducer(store, action); + expect(result.getIn(['countCache', '123'])).to.equal(456); + }); + }); +}); diff --git a/test/client/coral-framework/reducers/notificationReducer.spec.js b/test/client/coral-framework/reducers/notificationReducer.spec.js new file mode 100644 index 000000000..40c50d11a --- /dev/null +++ b/test/client/coral-framework/reducers/notificationReducer.spec.js @@ -0,0 +1,35 @@ +import {Map} from 'immutable'; +import {expect} from 'chai'; +import notificationReducer from '../../../../client/coral-framework/reducers/notification'; +import * as actions from '../../../../client/coral-framework/actions/notification'; + +describe ('notificationsReducer', () => { + describe('ADD_NOTIFICATION', () => { + it('should add a notification', () => { + const action = { + type: actions.ADD_NOTIFICATION, + text: 'Test notification', + notifType: 'test' + }; + const store = new Map({}); + const result = notificationReducer(store, action); + expect(result.get('text')).to.equal(action.text); + expect(result.get('type')).to.equal(action.notifType); + }); + }); + + describe('CLEAR_NOTIFICATION', () => { + it('should clear a notification', () => { + const action = { + type: actions.CLEAR_NOTIFICATION + }; + const store = new Map({ + text: 'Test notification', + type: 'test' + }); + const result = notificationReducer(store, action); + expect(result.get('text')).to.equal(''); + expect(result.get('type')).to.equal(''); + }); + }); +});