diff --git a/.eslintignore b/.eslintignore index b58e4f058..afa7bf16d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -13,5 +13,6 @@ plugins/* !plugins/coral-plugin-viewing-options !plugins/coral-plugin-comment-content !plugins/talk-plugin-permalink -!plugins/talk-plugin-featured +!plugins/talk-plugin-featured-comments + node_modules diff --git a/.gitignore b/.gitignore index 0e809b43a..bd8edcd94 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,6 @@ plugins/* !plugins/coral-plugin-viewing-options !plugins/coral-plugin-comment-content !plugins/talk-plugin-permalink -!plugins/talk-plugin-featured +!plugins/talk-plugin-featured-comments **/node_modules/* diff --git a/client/coral-embed-stream/src/actions/stream.js b/client/coral-embed-stream/src/actions/stream.js index d936879e9..7250b86f3 100644 --- a/client/coral-embed-stream/src/actions/stream.js +++ b/client/coral-embed-stream/src/actions/stream.js @@ -1,35 +1,24 @@ import pym from 'coral-framework/services/pym'; import * as actions from '../constants/stream'; +import {buildUrl} from 'coral-framework/utils'; +import queryString from 'query-string'; export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id}); -function removeParam(key, sourceURL) { - let rtn = sourceURL.split('?')[0]; - let param; - let params_arr = []; - let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : ''; - if (queryString !== '') { - params_arr = queryString.split('&'); - for (let i = params_arr.length - 1; i >= 0; i -= 1) { - param = params_arr[i].split('=')[0]; - if (param === key) { - params_arr.splice(i, 1); - } - } - rtn = `${rtn}?${params_arr.join('&')}`; - } - return rtn; -} - export const viewAllComments = () => { + const search = queryString.stringify({ + ...queryString.parse(location.search), + comment_id: undefined, + }); + // remove the comment_id url param - const modifiedUrl = removeParam('comment_id', location.href); + const url = buildUrl({...location, search}); try { // "window" here refers to the embedded iframe - window.history.replaceState({}, document.title, modifiedUrl); + window.history.replaceState({}, document.title, url); // also change the parent url pym.sendMessage('coral-view-all-comments'); @@ -38,6 +27,28 @@ export const viewAllComments = () => { return {type: actions.VIEW_ALL_COMMENTS}; }; +export const viewComment = (id) => { + + const search = queryString.stringify({ + ...queryString.parse(location.search), + comment_id: id, + }); + + // remove the comment_id url param + const url = buildUrl({...location, search}); + + try { + + // "window" here refers to the embedded iframe + window.history.replaceState({}, document.title, url); + + // also change the parent url + pym.sendMessage('coral-view-comment', id); + } catch (e) { /* not sure if we're worried about old browsers */ } + + return {type: actions.VIEW_COMMENT, id}; +}; + export const addCommentClassName = (className) => ({ type: actions.ADD_COMMENT_CLASSNAME, className diff --git a/client/coral-embed-stream/src/components/AllCommentsPane.js b/client/coral-embed-stream/src/components/AllCommentsPane.js index 6af72475a..7166aa6e9 100644 --- a/client/coral-embed-stream/src/components/AllCommentsPane.js +++ b/client/coral-embed-stream/src/components/AllCommentsPane.js @@ -125,8 +125,6 @@ class AllCommentsPane extends React.Component { root, comments, commentClassNames, - addTag, - removeTag, ignoreUser, setActiveReplyBox, activeReplyBox, @@ -173,8 +171,6 @@ class AllCommentsPane extends React.Component { currentUser={currentUser} postFlag={postFlag} postDontAgree={postDontAgree} - addTag={addTag} - removeTag={removeTag} ignoreUser={ignoreUser} commentIsIgnored={commentIsIgnored} loadMore={loadNewReplies} diff --git a/client/coral-embed-stream/src/components/Comment.css b/client/coral-embed-stream/src/components/Comment.css index 9a176534d..7eb2ece71 100644 --- a/client/coral-embed-stream/src/components/Comment.css +++ b/client/coral-embed-stream/src/components/Comment.css @@ -17,8 +17,6 @@ .comment { padding-left: 15px; - display: flex; - flex-flow: row; } .commentLevel0 { @@ -67,17 +65,6 @@ border-bottom: 1px solid rgba(255, 255, 255, 0.3); } -/* element in the top right of the Comment */ -.topRight { - float: right; - margin-top: 10px; - text-align: right; -} - -.topRight > * { - text-align: initial; -} - .topRight .popover { margin-top: 1em; right: 0px; @@ -89,11 +76,6 @@ border-bottom: 2px solid currentColor; } -.topRightMenu { - cursor: pointer; - margin-top: 5px; -} - .editCommentForm { margin-bottom: 10px; } @@ -144,7 +126,7 @@ } .commentInfoBar { - float: right; + margin-left: auto; } @keyframes enter { @@ -158,7 +140,6 @@ } .commentContainer { - flex: auto; } .commentAvatar { @@ -173,3 +154,19 @@ vertical-align: middle; font-size: 14px; } + +.commentFooter { + padding-top: 8px; +} + +.header { + display: flex; + align-items: center; +} + +.content { +} + +.footer { + +} diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 9e6a70efe..8e0539279 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -1,4 +1,5 @@ -import React, {PropTypes} from 'react'; +import React from 'react'; +import PropTypes from 'prop-types'; import AuthorName from 'coral-plugin-author-name/AuthorName'; import TagLabel from 'coral-plugin-tag-label/TagLabel'; @@ -13,13 +14,6 @@ import {THREADING_LEVEL} from '../constants/stream'; import merge from 'lodash/merge'; import mapValues from 'lodash/mapValues'; -import { - BestButton, - IfUserCanModifyBest, - BEST_TAG, - commentIsBest, - BestIndicator -} from 'coral-plugin-best/BestButton'; import LoadMore from './LoadMore'; import {getEditableUntilDate} from './util'; import {TopRightMenu} from './TopRightMenu'; @@ -178,19 +172,13 @@ export default class Comment extends React.Component { }).isRequired, // given a comment, return whether it should be rendered as ignored - commentIsIgnored: React.PropTypes.func, - - // dispatch action to add a tag to a comment - addTag: React.PropTypes.func, - - // dispatch action to remove a tag from a comment - removeTag: React.PropTypes.func, + commentIsIgnored: PropTypes.func, // dispatch action to ignore another user - ignoreUser: React.PropTypes.func, + ignoreUser: PropTypes.func, // edit a comment, passed (id, asset_id, { body }) - editComment: React.PropTypes.func, + editComment: PropTypes.func, } editComment = (...args) => { @@ -303,6 +291,8 @@ export default class Comment extends React.Component { render () { const { asset, + data, + root, depth, comment, postFlag, @@ -321,8 +311,6 @@ export default class Comment extends React.Component { addNotification, charCountEnable, showSignInDialog, - addTag, - removeTag, liveUpdates, commentIsIgnored, commentClassNames = [] @@ -347,40 +335,6 @@ export default class Comment extends React.Component { myFlag = dontAgreeSummary.find((s) => s.current_user); } - // call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar) - const notifyOnError = (fn, errorToMessage) => - async function(...args) { - if (typeof errorToMessage !== 'function') { - errorToMessage = (error) => error.message; - } - try { - return await fn(...args); - } catch (error) { - addNotification('error', errorToMessage(error)); - throw error; - } - }; - - const addBestTag = notifyOnError( - () => - addTag({ - id: comment.id, - name: BEST_TAG, - assetId: asset.id, - }), - () => 'Failed to tag comment as best' - ); - - const removeBestTag = notifyOnError( - () => - removeTag({ - id: comment.id, - name: BEST_TAG, - assetId: asset.id, - }), - () => 'Failed to remove best comment tag' - ); - /** * conditionClassNames * adds classNames based on condition @@ -424,6 +378,15 @@ export default class Comment extends React.Component { } ); + // props that are passed down the slots. + const slotProps = { + data, + root, + asset, + comment, + depth, + }; + return (
- - - {isStaff(comment.tags) ? Staff : null} - {commentIsBest(comment) - ? - : null } +
+ + {isStaff(comment.tags) ? Staff : null} - - - { - (comment.editing && comment.editing.edited) - ?  ({t('comment.edited')}) - : null - } - - - - - { (currentUser && (comment.user.id === currentUser.id)) && - - /* User can edit/delete their own comment for a short window after posting */ - + + { - commentIsStillEditable(comment) && - Edit + (comment.editing && comment.editing.edited) + ?  ({t('comment.edited')}) + : null } - } - { (currentUser && (comment.user.id !== currentUser.id)) && - /* TopRightMenu allows currentUser to ignore other users' comments */ - - + + + { (currentUser && (comment.user.id === currentUser.id)) && + + /* User can edit/delete their own comment for a short window after posting */ + + { + commentIsStillEditable(comment) && + Edit + } - } - { - this.state.isEditing - ? - :
- -
- } + } + { (currentUser && (comment.user.id !== currentUser.id)) && -
- - - - - - - {!disableReply && - - - } + /* TopRightMenu allows currentUser to ignore other users' comments */ + + + + }
-
- - - + { + this.state.isEditing + ? + :
+ +
+ } +
+
+
+ - + {!disableReply && + + + } +
+
+ + + + +
- {activeReplyBox === comment.id - ? { - setActiveReplyBox(''); - }} + {activeReplyBox === comment.id + ? { + setActiveReplyBox(''); + }} + charCountEnable={charCountEnable} + maxCharCount={maxCharCount} + setActiveReplyBox={setActiveReplyBox} + parentId={(depth < THREADING_LEVEL) ? comment.id : parentId} + addNotification={addNotification} + postComment={postComment} + currentUser={currentUser} + assetId={asset.id} + /> + : null} + + + {view.map((reply) => { + return commentIsIgnored(reply) + ? + : - : null} - - - {view.map((reply) => { - return commentIsIgnored(reply) - ? - : ; - })} - -
- + showSignInDialog={showSignInDialog} + commentIsIgnored={commentIsIgnored} + liveUpdates={liveUpdates} + reactKey={reply.id} + key={reply.id} + comment={reply} + />; + })} + +
+
); diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index 5fde7c1df..2a1381862 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -1,4 +1,5 @@ -import React, {PropTypes} from 'react'; +import React from 'react'; +import PropTypes from 'prop-types'; import {StreamError} from './StreamError'; import Comment from '../components/Comment'; import SuspendedAccount from './SuspendedAccount'; @@ -35,6 +36,29 @@ class Stream extends React.Component { if (!this.userIsDegraged(this.props) && this.userIsDegraged(next)) { this.setState({keepCommentBox: true}); } + + this.fallbackAllTab(next); + } + + componentDidMount() { + this.fallbackAllTab(); + } + + fallbackAllTab(props = this.props) { + if (props.activeStreamTab !== 'all') { + const slotPlugins = this.getSlotComponents('streamTabs', props).map((c) => c.talkPluginName); + if (slotPlugins.indexOf(props.activeStreamTab) === -1) { + props.setActiveStreamTab('all'); + } + } + } + + getSlotProps({data, root, root: {asset}} = this.props) { + return {data, root, asset}; + } + + getSlotComponents(slot, props = this.props) { + return getSlotComponents(slot, props.reduxState, this.getSlotProps(props)); } setActiveReplyBox = (id) => { @@ -66,7 +90,6 @@ class Stream extends React.Component { deleteAction, showSignInDialog, updateItem, - addTag, ignoreUser, activeStreamTab, setActiveStreamTab, @@ -74,8 +97,6 @@ class Stream extends React.Component { loadMoreComments, viewAllComments, auth: {loggedIn, user}, - removeTag, - reduxState, editName } = this.props; const {keepCommentBox} = this.state; @@ -99,7 +120,7 @@ class Stream extends React.Component { }; const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox); - const streamTabProps = {data, root, asset}; + const slotProps = this.getSlotProps(); if (!comment && !comments) { console.error('Talk: No comments came back from the graph given that query. Please, check the query params.'); @@ -158,7 +179,10 @@ class Stream extends React.Component {
:

{asset.settings.closedMessage}

} - + {loggedIn && ( - + - {getSlotComponents('streamTabs', reduxState, streamTabProps).map((PluginComponent) => ( + {this.getSlotComponents('streamTabs').map((PluginComponent) => ( @@ -222,10 +247,10 @@ class Stream extends React.Component { - {getSlotComponents('streamTabPanes', reduxState, streamTabProps).map((PluginComponent) => ( + {this.getSlotComponents('streamTabPanes').map((PluginComponent) => ( ))} @@ -235,8 +260,6 @@ class Stream extends React.Component { root={root} comments={comments} commentClassNames={commentClassNames} - addTag={addTag} - removeTag={removeTag} ignoreUser={ignoreUser} setActiveReplyBox={setActiveReplyBox} activeReplyBox={activeReplyBox} @@ -269,12 +292,6 @@ Stream.propTypes = { addNotification: PropTypes.func.isRequired, postComment: PropTypes.func.isRequired, - // dispatch action to add a tag to a comment - addTag: PropTypes.func, - - // dispatch action to remove a tag from a comment - removeTag: PropTypes.func, - // dispatch action to ignore another user ignoreUser: React.PropTypes.func, diff --git a/client/coral-embed-stream/src/components/Toggleable.css b/client/coral-embed-stream/src/components/Toggleable.css index 0ce8a119b..bbdd5e3d6 100644 --- a/client/coral-embed-stream/src/components/Toggleable.css +++ b/client/coral-embed-stream/src/components/Toggleable.css @@ -2,21 +2,25 @@ outline: none; } -/** +.toggler { + composes: buttonReset from "coral-framework/styles/reset.css"; +} + +/** * Up/Down Chevrons for the top right menu */ .chevron { } .chevron:before { content: '⌃'; - display: inline-block; + display: inline-block; position: relative; - top: 0.25em; + top: 0.25em; } -/* Down Arrow */ +/* Down Arrow */ .chevron.down:before { - display: inline-block; + display: inline-block; position: relative; transform: rotate(180deg); top: 0; diff --git a/client/coral-embed-stream/src/components/Toggleable.js b/client/coral-embed-stream/src/components/Toggleable.js index c7996d866..1c70f2468 100644 --- a/client/coral-embed-stream/src/components/Toggleable.js +++ b/client/coral-embed-stream/src/components/Toggleable.js @@ -27,9 +27,8 @@ export default class Toggleable extends React.Component { const {isOpen} = this.state; return ( - - {isOpen ? upArrow : downArrow} + + {isOpen ? children : null} diff --git a/client/coral-embed-stream/src/constants/stream.js b/client/coral-embed-stream/src/constants/stream.js index 622a86c18..7ac9427cf 100644 --- a/client/coral-embed-stream/src/constants/stream.js +++ b/client/coral-embed-stream/src/constants/stream.js @@ -1,6 +1,7 @@ export const SET_ACTIVE_REPLY_BOX = 'SET_ACTIVE_REPLY_BOX'; export const ADDTL_COMMENTS_ON_LOAD_MORE = 10; export const VIEW_ALL_COMMENTS = 'VIEW_ALL_COMMENTS'; +export const VIEW_COMMENT = 'VIEW_COMMENT'; export const ADD_COMMENT_CLASSNAME = 'ADD_COMMENT_CLASSNAME'; export const REMOVE_COMMENT_CLASSNAME = 'REMOVE_COMMENT_CLASSNAME'; export const THREADING_LEVEL = process.env.TALK_THREADING_LEVEL; diff --git a/client/coral-embed-stream/src/containers/Comment.js b/client/coral-embed-stream/src/containers/Comment.js index 479c6f302..5d43e9eb7 100644 --- a/client/coral-embed-stream/src/containers/Comment.js +++ b/client/coral-embed-stream/src/containers/Comment.js @@ -21,12 +21,19 @@ export default withFragments({ ${getSlotFragmentSpreads(slots, 'root')} } `, + asset: gql` + fragment CoralEmbedStream_Comment_asset on Asset { + __typename + ${getSlotFragmentSpreads(slots, 'asset')} + } + `, comment: gql` fragment CoralEmbedStream_Comment_comment on Comment { id body created_at status + replyCount tags { tag { name diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index aa0700ad5..a31dbe911 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -94,7 +94,7 @@ class EmbedContainer extends React.Component { if (!prevProps.root.comment && this.props.root.comment) { // Scroll to a permalinked comment if one is in the URL once the page is done rendering. - setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0); + setTimeout(() => pym.scrollParentToChildEl('talk-embed-stream-container'), 0); } } diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index 6be8b9ee6..ea0676e51 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -4,8 +4,8 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import {ADDTL_COMMENTS_ON_LOAD_MORE, THREADING_LEVEL} from '../constants/stream'; import { - withPostComment, withPostFlag, withPostDontAgree, withDeleteAction, - withAddTag, withRemoveTag, withIgnoreUser, withEditComment, + withPostComment, withPostFlag, withPostDontAgree, + withDeleteAction, withIgnoreUser, withEditComment } from 'coral-framework/graphql/mutations'; import * as authActions from 'coral-framework/actions/auth'; @@ -159,7 +159,6 @@ const commentFragment = gql` id ...${getDefinitionName(Comment.fragments.comment)} ${nest(` - replyCount(excludeIgnored: $excludeIgnored) replies(excludeIgnored: $excludeIgnored) { nodes { id @@ -210,7 +209,6 @@ const LOAD_MORE_QUERY = gql` id ...${getDefinitionName(Comment.fragments.comment)} ${nest(` - replyCount(excludeIgnored: $excludeIgnored) replies(limit: 3, excludeIgnored: $excludeIgnored) { nodes { id @@ -278,6 +276,7 @@ const fragments = { endCursor } ${getSlotFragmentSpreads(slots, 'asset')} + ...${getDefinitionName(Comment.fragments.asset)} } me { status @@ -291,6 +290,7 @@ const fragments = { ${getSlotFragmentSpreads(slots, 'root')} ...${getDefinitionName(Comment.fragments.root)} } + ${Comment.fragments.asset} ${Comment.fragments.root} ${commentFragment} `, @@ -329,8 +329,6 @@ export default compose( withPostComment, withPostFlag, withPostDontAgree, - withAddTag, - withRemoveTag, withIgnoreUser, withDeleteAction, withEditComment, diff --git a/client/coral-embed-stream/src/graphql/utils.js b/client/coral-embed-stream/src/graphql/utils.js index 523cc571f..4f51b0ae2 100644 --- a/client/coral-embed-stream/src/graphql/utils.js +++ b/client/coral-embed-stream/src/graphql/utils.js @@ -1,4 +1,6 @@ import update from 'immutability-helper'; +import {insertCommentsSorted} from 'coral-framework/utils'; + function determineCommentDepth(comment) { let depth = 0; let cur = comment; @@ -142,14 +144,6 @@ export function findCommentInEmbedQuery(root, callbackOrId) { return findComment(root.asset.comments.nodes, callback); } -const ascending = (a, b) => { - const dateA = new Date(a.created_at); - const dateB = new Date(b.created_at); - if (dateA < dateB) { return -1; } - if (dateA > dateB) { return 1; } - return 0; -}; - function findAndInsertFetchedComments(parent, comments, parent_id) { const isAsset = parent.__typename === 'Asset'; const connectionField = isAsset ? 'comments' : 'replies'; @@ -162,11 +156,9 @@ function findAndInsertFetchedComments(parent, comments, parent_id) { if (isAsset) { return nodes.concat(comments.nodes); } - return nodes - .concat(comments.nodes.filter( + return insertCommentsSorted(nodes, comments.nodes.filter( (comment) => !nodes.some((node) => node.id === comment.id) - )) - .sort(ascending); + )); }}, }, }); diff --git a/client/coral-embed-stream/src/reducers/stream.js b/client/coral-embed-stream/src/reducers/stream.js index 4f4fcf47f..86d5301b7 100644 --- a/client/coral-embed-stream/src/reducers/stream.js +++ b/client/coral-embed-stream/src/reducers/stream.js @@ -17,11 +17,11 @@ function getQueryVariable(variable) { const initialState = { activeReplyBox: '', - assetId: getQueryVariable('asset_id'), - assetUrl: getQueryVariable('asset_url'), - commentId: getQueryVariable('comment_id'), + assetId: getQueryVariable('asset_id') || '', + assetUrl: getQueryVariable('asset_url') || '', + commentId: getQueryVariable('comment_id') || '', commentClassNames: [], - activeTab: 'all', + activeTab: process.env.TALK_DEFAULT_STREAM_TAB, previousTab: '', }; @@ -48,6 +48,11 @@ export default function stream(state = initialState, action) { ...state, commentId: '', }; + case actions.VIEW_COMMENT: + return { + ...state, + commentId: action.id, + }; case actions.ADD_COMMENT_CLASSNAME : return { ...state, diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index 702308c3a..eb61f3b90 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -1,7 +1,8 @@ import pym from 'pym.js'; import URLSearchParams from 'url-search-params'; -import {stringify} from 'querystring'; +import {buildUrl} from 'coral-framework/utils'; +import queryString from 'query-string'; // TODO: Styles should live in a separate file const snackbarStyles = { @@ -39,7 +40,7 @@ function buildStreamIframeUrl(talkBaseUrl, query) { 'embed/stream?' ].join(''); - url += stringify(query); + url += queryString.stringify(query); return url; } @@ -98,10 +99,37 @@ function configurePymParent(pymParent, opts) { // remove the permalink comment id from the hash pymParent.onMessage('coral-view-all-comments', function() { + + const search = queryString.stringify({ + ...queryString.parse(location.search), + commentId: undefined, + }); + + // remove the commentId url param + const url = buildUrl({...location, search}); + window.history.replaceState( {}, document.title, - location.origin + location.pathname + location.search + url, + ); + }); + + // remove the permalink comment id from the hash + pymParent.onMessage('coral-view-comment', function(id) { + + const search = queryString.stringify({ + ...queryString.parse(location.search), + commentId: id, + }); + + // remove the commentId url param + const url = buildUrl({...location, search}); + + window.history.replaceState( + {}, + document.title, + url, ); }); @@ -193,12 +221,18 @@ Talk.render = function(el, opts) { let urlParams = new URLSearchParams(window.location.search); - query.comment_id = urlParams.get('commentId'); + if (urlParams.get('commentId')) { + query.comment_id = urlParams.get('commentId'); + } - query.asset_id = opts.asset_id; + if (opts.asset_id) { + query.asset_id = opts.asset_id; + } - query.asset_url = opts.asset_url; - if (!query.asset_url) { + if (opts.asset_url) { + query.asset_url = opts.asset_url; + } + else { try { query.asset_url = document.querySelector('link[rel="canonical"]').href; } catch (e) { diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js index 3b9ef01f4..91d2ae0ab 100644 --- a/client/coral-framework/graphql/mutations.js +++ b/client/coral-framework/graphql/mutations.js @@ -1,6 +1,117 @@ import {gql} from 'react-apollo'; import withMutation from '../hocs/withMutation'; +function convertItemType(item_type) { + switch (item_type) { + case 'COMMENTS': + return 'Comment'; + case 'USERS': + return 'User'; + case 'ASSETS': + return 'Asset'; + default: + throw new Error(`Unknown item_type ${item_type}`); + } +} + +function getTagFragment(item_type) { + return gql` + fragment Coral_UpdateFragment on ${convertItemType(item_type)} { + tags { + tag { + name + } + } + } + `; +} + +export const withAddTag = withMutation( + gql` + mutation AddTag($id: ID!, $asset_id: ID!, $name: String!, $item_type: TAGGABLE_ITEM_TYPE!) { + addTag(tag: {name: $name, id: $id, item_type: $item_type, asset_id: $asset_id}) { + ...ModifyTagResponse + } + } + `, { + props: ({mutate}) => ({ + addTag: ({id, name, assetId, itemType}) => { + return mutate({ + variables: { + id, + name, + asset_id: assetId, + item_type: itemType, + }, + optimisticResponse: { + addTag: { + __typename: 'ModifyTagResponse', + errors: null, + } + }, + update: (proxy) => { + const fragmentId = `${convertItemType(itemType)}_${id}`; + const fragment = getTagFragment(itemType); + + // Read the data from our cache for this query. + const data = proxy.readFragment({fragment, id: fragmentId}); + + data.tags.push({ + tag: { + __typename: 'Tag', + name + }, + __typename: 'TagLink' + }); + + // Write our data back to the cache. + proxy.writeFragment({fragment, id: fragmentId, data}); + }, + }); + }}), + }); + +export const withRemoveTag = withMutation( + gql` + mutation RemoveTag($id: ID!, $asset_id: ID!, $name: String!, $item_type: TAGGABLE_ITEM_TYPE!) { + removeTag(tag: {name: $name, id: $id, item_type: $item_type, asset_id: $asset_id}) { + ...ModifyTagResponse + } + } + `, { + props: ({mutate}) => ({ + removeTag: ({id, name, assetId, itemType}) => { + return mutate({ + variables: { + id, + name, + asset_id: assetId, + item_type: itemType, + }, + optimisticResponse: { + removeTag: { + __typename: 'ModifyTagResponse', + errors: null, + } + }, + update: (proxy) => { + const fragmentId = `${convertItemType(itemType)}_${id}`; + const fragment = getTagFragment(itemType); + + // Read the data from our cache for this query. + const data = proxy.readFragment({fragment, id: fragmentId}); + + const idx = data.tags.findIndex((i) => i.tag.name === name); + + data.tags = [...data.tags.slice(0, idx), ...data.tags.slice(idx + 1)]; + + // Write our data back to the cache. + proxy.writeFragment({fragment, id: fragmentId, data}); + } + }); + }}), + }); + export const withSetCommentStatus = withMutation( gql` mutation SetCommentStatus($commentId: ID!, $status: COMMENT_STATUS!){ @@ -173,98 +284,6 @@ export const withDeleteAction = withMutation( }}), }); -const COMMENT_FRAGMENT = gql` - fragment CoralBest_UpdateFragment on Comment { - tags { - tag { - name - } - } - } - `; - -export const withAddTag = withMutation( - gql` - mutation AddTag($id: ID!, $asset_id: ID!, $name: String!) { - addTag(tag: {name: $name, id: $id, item_type: COMMENTS, asset_id: $asset_id}) { - ...ModifyTagResponse - } - } - `, { - props: ({mutate}) => ({ - addTag: ({id, name, assetId}) => { - return mutate({ - variables: { - id, - name, - asset_id: assetId - }, - optimisticResponse: { - addTag: { - __typename: 'ModifyTagResponse', - errors: null, - } - }, - update: (proxy) => { - const fragmentId = `Comment_${id}`; - - // Read the data from our cache for this query. - const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId}); - - data.tags.push({ - tag: { - __typename: 'Tag', - name: 'BEST' - }, - __typename: 'TagLink' - }); - - // Write our data back to the cache. - proxy.writeFragment({fragment: COMMENT_FRAGMENT, id: fragmentId, data}); - }, - }); - }}), - }); - -export const withRemoveTag = withMutation( - gql` - mutation RemoveTag($id: ID!, $asset_id: ID!, $name: String!) { - removeTag(tag: {name: $name, id: $id, item_type: COMMENTS, asset_id: $asset_id}) { - ...ModifyTagResponse - } - } - `, { - props: ({mutate}) => ({ - removeTag: ({id, name, assetId}) => { - return mutate({ - variables: { - id, - name, - asset_id: assetId - }, - optimisticResponse: { - removeTag: { - __typename: 'ModifyTagResponse', - errors: null, - } - }, - update: (proxy) => { - const fragmentId = `Comment_${id}`; - - // Read the data from our cache for this query. - const data = proxy.readFragment({fragment: COMMENT_FRAGMENT, id: fragmentId}); - - const idx = data.tags.findIndex((i) => i.tag.name === 'BEST'); - - data.tags = [...data.tags.slice(0, idx), ...data.tags.slice(idx + 1)]; - - // Write our data back to the cache. - proxy.writeFragment({fragment: COMMENT_FRAGMENT, id: fragmentId, data}); - } - }); - }}), - }); - export const withIgnoreUser = withMutation( gql` mutation IgnoreUser($id: ID!) { diff --git a/client/coral-framework/helpers/plugins.js b/client/coral-framework/helpers/plugins.js index cc8147c19..1af2a9a59 100644 --- a/client/coral-framework/helpers/plugins.js +++ b/client/coral-framework/helpers/plugins.js @@ -2,12 +2,12 @@ import React from 'react'; import uniq from 'lodash/uniq'; import pick from 'lodash/pick'; import merge from 'lodash/merge'; -import plugins from 'pluginsConfig'; -import flatten from 'lodash/flatten'; import flattenDeep from 'lodash/flattenDeep'; +import flatten from 'lodash/flatten'; import {loadTranslations} from 'coral-framework/services/i18n'; import {injectReducers} from 'coral-framework/services/store'; import camelize from './camelize'; +import plugins from 'pluginsConfig'; export function getSlotComponents(slot, reduxState, props = {}) { const pluginConfig = reduxState.config.pluginConfig || {}; diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 61413e03d..3768b3940 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -146,6 +146,38 @@ export function forEachError(error, 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; +}; + +const descending = (a, b) => ascending(a, b) * -1; + +export function insertCommentsSorted(nodes, comments, sortOrder = 'CHRONOLOGICAL') { + const added = nodes.concat(comments); + if (sortOrder === 'CHRONOLOGICAL') { + return added.sort(ascending); + } + if (sortOrder === 'REVERSE_CHRONOLOGICAL') { + return added.sort(descending); + } + throw new Error(`Unknown sort order ${sortOrder}`); +} + +export const isTagged = (tags, which) => tags.some((t) => t.tag.name === which); + +export function buildUrl({protocol, hostname, port, pathname, search, hash} = window.location) { + if (search && search[0] !== '?') { + search = `?${search}`; + } else if (search === '?') { + search = ''; + } + return `${protocol}//${hostname}${port ? `:${port}` : ''}${pathname}${search}${hash}`; +} + /** * getSlotFragmentSpreads will return a string in the * expected format for slot fragments, given `slots` and `resource`. diff --git a/client/coral-plugin-best/BestButton.css b/client/coral-plugin-best/BestButton.css deleted file mode 100644 index ca77f01f2..000000000 --- a/client/coral-plugin-best/BestButton.css +++ /dev/null @@ -1,14 +0,0 @@ -.button { - composes: buttonReset from "coral-framework/styles/reset.css"; - margin: 5px 10px 5px 0px; -} - -.tagIcon { - font-size: 12px; - vertical-align: middle; -} - -.icon { - font-size: 12px; - vertical-align: middle; -} diff --git a/client/coral-plugin-best/BestButton.js b/client/coral-plugin-best/BestButton.js deleted file mode 100644 index 34bdad201..000000000 --- a/client/coral-plugin-best/BestButton.js +++ /dev/null @@ -1,105 +0,0 @@ -import React, {Component, PropTypes} from 'react'; - -import t from 'coral-framework/services/i18n'; - -import {Icon} from 'coral-ui'; -import cn from 'classnames'; -import styles from './BestButton.css'; - -// tag string for best comments -export const BEST_TAG = 'BEST'; - -export const commentIsBest = ({tags} = {}) => tags.some((t) => t.tag.name === BEST_TAG); - -const name = 'coral-plugin-best'; - -// It would be best if the backend/api held this business logic -const canModifyBestTag = ({roles = []} = {}) => roles && ['ADMIN', 'MODERATOR'].some((role) => roles.includes(role)); - -// Put this on a comment to show that it is best - -export const BestIndicator = ({children = }) => ( - - { children } - -); - -/** - * Component that only renders children if the provided user prop can modify best tags - */ -export const IfUserCanModifyBest = ({user, children}) => { - if (!(user && canModifyBestTag(user))) {return null;} - return children; -}; - -/** - * Button that lets a moderator tag a comment as "Best". - * Used to recognize really good comments. - */ -export class BestButton extends Component { - static propTypes = { - - // whether the comment is already tagged as best - isBest: PropTypes.bool.isRequired, - - // set that this comment is best - addBest: PropTypes.func.isRequired, - - // remove the best status - removeBest: PropTypes.func.isRequired, - } - - constructor(props) { - super(props); - this.onClickAddBest = this.onClickAddBest.bind(this); - this.onClickRemoveBest = this.onClickRemoveBest.bind(this); - } - - state = { - isSaving: false - } - - async onClickAddBest(e) { - e.preventDefault(); - const {addBest} = this.props; - if (!addBest) { - console.warn('BestButton#onClickAddBest called even though there is no addBest prop. doing nothing'); - return; - } - this.setState({isSaving: true}); - try { - await addBest(); - } finally { - this.setState({isSaving: false}); - } - } - - async onClickRemoveBest(e) { - e.preventDefault(); - const {removeBest} = this.props; - if (!removeBest) { - console.warn('BestButton#onClickAddBest called even though there is no removeBest prop. doing nothing'); - return; - } - this.setState({isSaving: true}); - try { - await removeBest(); - } finally { - this.setState({isSaving: false}); - } - } - - render() { - const {isBest, addBest, removeBest} = this.props; - const {isSaving} = this.state; - const disabled = isSaving || !(isBest ? removeBest : addBest); - return ( - - ); - } -} diff --git a/locales/en.yml b/locales/en.yml index e3bf42e5e..af985b593 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -32,7 +32,6 @@ en: comment_post_notif_premod: "Thank you for posting. Our moderation team will review your comment shortly." comment_post_banned_word: "Your comment contains one or more words that are not permitted, so it will not be published. If you think this message is incorrect, please contact our moderation team." characters_remaining: "characters remaining" - comment_is_best: "This comment is one of the best" comment_offensive: "This comment is offensive" comment_singular: Comment comment_plural: Comments @@ -306,7 +305,6 @@ en: report_notif: "Thank you for reporting this comment. Our moderation team has been notified and will review it shortly." report_notif_remove: "Your report has been removed." reported: Reported - set_best: "Tag as Best" settings: all_comments: "All Comments" from_settings_page: "From the Profile Page you can see your comment history." @@ -363,7 +361,6 @@ en: write_message: "Write a message" send: Send thank_you: "We value your safety and feedback. A moderator will review your report." - unset_best: "Untag as Best" user: bio_flags: "flags for this bio" user_bio: "User Bio" diff --git a/locales/es.yml b/locales/es.yml index fe6030b74..963723e9e 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -32,7 +32,6 @@ es: comment_post_notif_premod: "Gracias por el comentario. Nuestro equipo de moderación va a revisarlo muy pronto." comment_post_banned_word: "Tu comentario contiene una o más palabras que no están permitidas en nuestro espacio, por lo que no será publicado. Si crees que es un error, por favor contacta a nuestro equipo de moderación." characters_remaining: "carácteres restantes" - comment_is_best: "Este comentario es uno de los mejores" comment_offensive: "Este comentario es ofensivo" comment_singular: Comentario comment_plural: Comentarios @@ -298,7 +297,6 @@ es: report_notif: "Gracias por reportar este comentario. Nuestro equipo de moderación ha sido notificado y muy pronto lo va a revisar." report_notif_remove: "Tu reporte ha sido eliminado." reported: "Reportado" - set_best: "Etiquetar como el mejor" settings: all_comments: "Todos los comentarios" from_settings_page: "Desde la página de configuración puedes ver tu historial de comentarios." @@ -386,7 +384,6 @@ es: write_message: "Escribir un mensaje" send: "Enviar" thank_you: "Valoramos tanto su seguridad en este espacio como sus comentarios. Un o una moderadora va a leer su reporte." - unset_best: "Des-etiquetar como el mejor" user: bio_flags: "reportes para este bio" user_bio: "Bio de Usuario" diff --git a/package.json b/package.json index 3e3254fd4..9cb065320 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "passport-jwt": "^2.2.1", "passport-local": "^1.0.0", "prop-types": "^15.5.10", + "query-strings": "^0.0.1", "react-apollo": "^1.1.0", "react-input-autosize": "^1.1.4", "react-recaptcha": "^2.2.6", diff --git a/plugin-api/beta/client/actions/notification.js b/plugin-api/beta/client/actions/notification.js new file mode 100644 index 000000000..c1a75cb7a --- /dev/null +++ b/plugin-api/beta/client/actions/notification.js @@ -0,0 +1 @@ +export {addNotification} from 'coral-framework/actions/notification'; diff --git a/plugin-api/beta/client/hocs/index.js b/plugin-api/beta/client/hocs/index.js index 78408f6c2..68547692d 100644 --- a/plugin-api/beta/client/hocs/index.js +++ b/plugin-api/beta/client/hocs/index.js @@ -1,4 +1,5 @@ export {default as withReaction} from './withReaction'; +export {default as withTags} from './withTags'; export {default as withFragments} from 'coral-framework/hocs/withFragments'; export {default as excludeIf} from 'coral-framework/hocs/excludeIf'; export {default as connect} from 'coral-framework/hocs/connect'; diff --git a/plugin-api/beta/client/hocs/withTags.js b/plugin-api/beta/client/hocs/withTags.js new file mode 100644 index 000000000..75d434bef --- /dev/null +++ b/plugin-api/beta/client/hocs/withTags.js @@ -0,0 +1,111 @@ +import React from 'react'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {compose, gql} from 'react-apollo'; +import {getDisplayName} from 'coral-framework/helpers/hoc'; +import {capitalize} from 'coral-framework/helpers/strings'; +import {withAddTag, withRemoveTag} from 'coral-framework/graphql/mutations'; +import withFragments from 'coral-framework/hocs/withFragments'; +import {addNotification} from 'coral-framework/actions/notification'; +import {forEachError, isTagged} from 'coral-framework/utils'; + +export default (tag) => (WrappedComponent) => { + if (typeof tag !== 'string') { + console.error('Tag must be a valid string'); + return null; + } + + const Tag = capitalize(tag); + const TAG = tag.toUpperCase(); + + class WithTags extends React.Component { + loading = false; + + postTag = () => { + const {comment, asset, addNotification} = this.props; + + if (this.loading) { + return; + } + + this.loading = true; + + this.props.addTag({ + id: comment.id, + name: TAG, + assetId: asset.id, + itemType: 'COMMENTS', + }) + .then(() => { + this.loading = false; + }) + .catch((err) => { + this.loading = false; + forEachError(err, ({msg}) => addNotification('error', msg)); + }); + } + + deleteTag = () => { + const {comment, asset, addNotification} = this.props; + + if (this.loading) { + return; + } + + this.props.removeTag({ + id: comment.id, + name: TAG, + assetId: asset.id, + itemType: 'COMMENTS', + }) + .then(() => { + this.loading = false; + }) + .catch((err) => { + this.loading = false; + forEachError(err, ({msg}) => addNotification('error', msg)); + }); + } + + render() { + const {comment} = this.props; + + const alreadyTagged = isTagged(comment.tags, TAG); + + return ; + } + } + + const mapStateToProps = (state) => ({ + user: state.auth.toJS().user, + }); + + const mapDispatchToProps = (dispatch) => + bindActionCreators({addNotification}, dispatch); + + const enhance = compose( + withFragments({ + comment: gql` + fragment ${Tag}Button_comment on Comment { + tags { + tag { + name + } + } + }` + }), + connect(mapStateToProps, mapDispatchToProps), + withAddTag, + withRemoveTag + ); + + WithTags.displayName = `WithTags(${getDisplayName(WrappedComponent)})`; + + return enhance(WithTags); +}; diff --git a/plugin-api/beta/client/services/index.js b/plugin-api/beta/client/services/index.js index 4e94a782a..54ad90fe7 100644 --- a/plugin-api/beta/client/services/index.js +++ b/plugin-api/beta/client/services/index.js @@ -1,4 +1,4 @@ -export {t} from 'coral-framework/services/i18n'; +export {t, timeago} from 'coral-framework/services/i18n'; export {can} from 'coral-framework/services/perms'; import {isSlotEmpty as ise} from 'coral-framework/helpers/plugins'; diff --git a/plugin-api/beta/client/utils/index.js b/plugin-api/beta/client/utils/index.js index 44c3af6c0..e0ff635e0 100644 --- a/plugin-api/beta/client/utils/index.js +++ b/plugin-api/beta/client/utils/index.js @@ -1 +1,7 @@ -export {getSlotFragmentSpreads} from 'coral-framework/utils'; +export { + isTagged, + insertCommentsSorted, + getSlotFragmentSpreads, + forEachError, + getDefinitionName, +} from 'coral-framework/utils'; diff --git a/plugins.default.json b/plugins.default.json index 425dccf93..4a1ea3282 100644 --- a/plugins.default.json +++ b/plugins.default.json @@ -3,7 +3,8 @@ "coral-plugin-auth", "coral-plugin-respect", "coral-plugin-offtopic", - "coral-plugin-facebook-auth" + "coral-plugin-facebook-auth", + "talk-plugin-featured-comments" ], "client": [ "coral-plugin-respect", @@ -11,6 +12,7 @@ "coral-plugin-offtopic", "coral-plugin-viewing-options", "coral-plugin-comment-content", - "talk-plugin-permalink" + "talk-plugin-permalink", + "talk-plugin-featured-comments" ] } diff --git a/plugins/coral-plugin-like/client/styles.css b/plugins/coral-plugin-like/client/styles.css index 859581bd4..a1d88b1b0 100644 --- a/plugins/coral-plugin-like/client/styles.css +++ b/plugins/coral-plugin-like/client/styles.css @@ -9,6 +9,7 @@ padding: 0px; border: none; font-size: inherit; + vertical-align: middle; &:hover { color: #767676; diff --git a/plugins/coral-plugin-offtopic/client/components/OffTopicTag.js b/plugins/coral-plugin-offtopic/client/components/OffTopicTag.js index 0e028c6bf..0277072eb 100644 --- a/plugins/coral-plugin-offtopic/client/components/OffTopicTag.js +++ b/plugins/coral-plugin-offtopic/client/components/OffTopicTag.js @@ -1,13 +1,12 @@ import React from 'react'; import styles from './styles.css'; import {t} from 'plugin-api/beta/client/services'; - -const isOffTopic = (tags) => !!tags.filter((t) => t.tag.name === 'OFF_TOPIC').length; +import {isTagged} from 'plugin-api/beta/client/utils'; export default (props) => ( { - isOffTopic(props.comment.tags) && props.depth === 0 ? ( + isTagged(props.comment.tags, 'OFF_TOPIC') && props.depth === 0 ? ( {t('off_topic')} diff --git a/plugins/coral-plugin-respect/client/styles.css b/plugins/coral-plugin-respect/client/styles.css index 362e02b8f..1022fe20d 100644 --- a/plugins/coral-plugin-respect/client/styles.css +++ b/plugins/coral-plugin-respect/client/styles.css @@ -9,6 +9,7 @@ padding: 0px; border: none; font-size: inherit; + vertical-align: middle; &:hover { color: #767676; diff --git a/plugins/talk-plugin-featured/client/.babelrc b/plugins/talk-plugin-featured-comments/client/.babelrc similarity index 99% rename from plugins/talk-plugin-featured/client/.babelrc rename to plugins/talk-plugin-featured-comments/client/.babelrc index 63b1c53de..60be246eb 100644 --- a/plugins/talk-plugin-featured/client/.babelrc +++ b/plugins/talk-plugin-featured-comments/client/.babelrc @@ -11,4 +11,4 @@ "transform-async-to-generator", "transform-react-jsx" ] -} +} \ No newline at end of file diff --git a/plugins/talk-plugin-featured/client/.eslintrc.json b/plugins/talk-plugin-featured-comments/client/.eslintrc.json similarity index 100% rename from plugins/talk-plugin-featured/client/.eslintrc.json rename to plugins/talk-plugin-featured-comments/client/.eslintrc.json diff --git a/plugins/talk-plugin-featured-comments/client/components/.babelrc b/plugins/talk-plugin-featured-comments/client/components/.babelrc new file mode 100644 index 000000000..60be246eb --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/.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/talk-plugin-featured-comments/client/components/Button.css b/plugins/talk-plugin-featured-comments/client/components/Button.css new file mode 100644 index 000000000..11e35d40b --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/Button.css @@ -0,0 +1,21 @@ +.button { + + /* TODO: figure out the best location to include the `reset.css` */ + composes: buttonReset from "coral-framework/styles/reset.css"; + + color: #2a2a2a; + margin-right: 10px; +} + +.button { + color: #767676; +} + +.button.featured { + color: #10589b; +} + +.icon { + font-size: 18px; + vertical-align: top; +} diff --git a/plugins/talk-plugin-featured-comments/client/components/Button.js b/plugins/talk-plugin-featured-comments/client/components/Button.js new file mode 100644 index 000000000..b9d0cc95f --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/Button.js @@ -0,0 +1,27 @@ +import React from 'react'; +import cn from 'classnames'; +import styles from './Button.css'; +import {pluginName} from '../../package.json'; +import {can} from 'plugin-api/beta/client/services'; +import {withTags} from 'plugin-api/beta/client/hocs'; +import {Icon} from 'plugin-api/beta/client/components/ui'; + +const Button = (props) => { + const {alreadyTagged, deleteTag, postTag, user} = props; + + return can(user, 'MODERATE_COMMENTS') ? ( + + ) : null ; +}; + +export default withTags('featured')(Button); + diff --git a/plugins/talk-plugin-featured-comments/client/components/Comment.css b/plugins/talk-plugin-featured-comments/client/components/Comment.css new file mode 100644 index 000000000..36f3e790e --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/Comment.css @@ -0,0 +1,67 @@ +.root { + margin: 10px 0 35px; +} + +.root:last-child { + margin: 10px 0; +} + +.goTo { + color: #1d5294; + font-size: 13px; + padding: 5px 0; + display: inline-block; +} + +.repliesIcon { + font-size: 16px; + vertical-align: middle; + line-height: 13px; + font-weight: bold; +} + +.goToIcon { + font-size: 16px; + vertical-align: middle; + line-height: 13px; + font-weight: bold; +} + +.goTo { + + /* TODO: figure out the best location to include the `reset.css` */ + composes: buttonReset from "coral-framework/styles/reset.css"; +} + +.goTo:hover { + text-decoration: underline; +} + +.quote { + line-height: 20px; + text-align: left; + letter-spacing: 0.1px; + margin: 0; + quotes: '\201c' '\201d'; + margin-bottom: 10px; +} + +.quote:before { + content: open-quote; +} + +.quote:after { + content: close-quote; +} + +.footer { + display: flex; +} + +.reactionsContainer, .actionsContainer { + flex: auto; +} + +.actionsContainer { + text-align: right; +} diff --git a/plugins/talk-plugin-featured-comments/client/components/Comment.js b/plugins/talk-plugin-featured-comments/client/components/Comment.js new file mode 100644 index 000000000..01b5f864c --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/Comment.js @@ -0,0 +1,56 @@ +import React from 'react'; +import cn from 'classnames'; +import styles from './Comment.css'; +import {t, timeago} from 'plugin-api/beta/client/services'; +import {Slot} from 'plugin-api/beta/client/components'; +import {Icon} from 'plugin-api/beta/client/components/ui'; +import {pluginName} from '../../package.json'; + +class Comment extends React.Component { + + viewComment = () => { + this.props.viewComment(this.props.comment.id); + } + + render() { + const {comment, asset, root, data} = this.props; + return ( +
+ +
+ {comment.body} +
+ +
+ + {comment.user.username} + + + ,{' '}{timeago(comment.created_at)} + +
+ +
+
+ + +
+
+ +
+
+
+ ); + } +} + +export default Comment; diff --git a/plugins/talk-plugin-featured-comments/client/components/LoadMore.js b/plugins/talk-plugin-featured-comments/client/components/LoadMore.js new file mode 100644 index 000000000..a168ed43a --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/LoadMore.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {Button} from 'coral-ui'; +import t from 'coral-framework/services/i18n'; +import cn from 'classnames'; + +class LoadMore extends React.Component { + render () { + const {loadingState, loadMore} = this.props; + const disabled = loadingState === 'loading'; + return ( +
+ +
+ ); + } +} + +LoadMore.propTypes = { + loadMore: PropTypes.func.isRequired, + loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']), +}; + +export default LoadMore; diff --git a/plugins/talk-plugin-featured-comments/client/components/Tab.js b/plugins/talk-plugin-featured-comments/client/components/Tab.js new file mode 100644 index 000000000..c780887c6 --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/Tab.js @@ -0,0 +1,9 @@ +import React from 'react'; +import {TabCount} from 'plugin-api/beta/client/components/ui'; +import {t} from 'plugin-api/beta/client/services'; + +export default ({active, asset: {featuredCommentsCount}}) => ( + + {t('talk-plugin-featured-comments.featured')} {featuredCommentsCount} + +); diff --git a/plugins/talk-plugin-featured-comments/client/components/TabPane.js b/plugins/talk-plugin-featured-comments/client/components/TabPane.js new file mode 100644 index 000000000..3cf8b2cfd --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/TabPane.js @@ -0,0 +1,47 @@ +import React from 'react'; +import Comment from '../containers/Comment'; +import LoadMore from './LoadMore'; +import {forEachError} from 'plugin-api/beta/client/utils'; + +class TabPane extends React.Component { + state = { + loadingState: '', + }; + + loadMore = () => { + this.setState({loadingState: 'loading'}); + this.props.loadMore() + .then(() => { + this.setState({loadingState: 'success'}); + }) + .catch((error) => { + this.setState({loadingState: 'error'}); + forEachError(error, ({msg}) => {this.props.addNotification('error', msg);}); + }); + } + + render() { + const {root, data, asset: {featuredComments, ...asset}, viewComment} = this.props; + return ( +
+ {featuredComments.nodes.map((comment) => + + )} + {featuredComments.hasNextPage && + + } +
+ ); + } +} + +export default TabPane; diff --git a/plugins/talk-plugin-featured-comments/client/components/Tag.css b/plugins/talk-plugin-featured-comments/client/components/Tag.css new file mode 100644 index 000000000..8874bfa0c --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/Tag.css @@ -0,0 +1,20 @@ +.tagIcon { + font-size: 12px; + vertical-align: middle; +} + +.icon { + font-size: 12px; + vertical-align: middle; +} + +.tag { + background-color: #10589b; + font-size: 12px; + font-weight: bold; + color: white; + display: inline-block; + margin: 0px 5px; + padding: 5px 5px; + border-radius: 2px; +} \ No newline at end of file diff --git a/plugins/talk-plugin-featured-comments/client/components/Tag.js b/plugins/talk-plugin-featured-comments/client/components/Tag.js new file mode 100644 index 000000000..4afa9629a --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/components/Tag.js @@ -0,0 +1,16 @@ +import React from 'react'; +import styles from './Tag.css'; +import {t} from 'plugin-api/beta/client/services'; +import {isTagged} from 'plugin-api/beta/client/utils'; + +export default (props) => ( + + { + isTagged(props.comment.tags, 'FEATURED') && props.depth === 0 ? ( + + {t('talk-plugin-featured-comments.featured')} + + ) : null + } + +); diff --git a/plugins/talk-plugin-featured-comments/client/containers/Comment.js b/plugins/talk-plugin-featured-comments/client/containers/Comment.js new file mode 100644 index 000000000..bba764a1d --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/containers/Comment.js @@ -0,0 +1,41 @@ +import {gql} from 'react-apollo'; +import Comment from '../components/Comment'; +import {withFragments} from 'plugin-api/beta/client/hocs'; +import {getSlotFragmentSpreads} from 'plugin-api/beta/client/utils'; + +const slots = [ + 'commentReactions', +]; + +export default withFragments({ + root: gql` + fragment TalkFeaturedComments_Comment_root on RootQuery { + __typename + ${getSlotFragmentSpreads(slots, 'root')} + } + `, + asset: gql` + fragment TalkFeaturedComments_Comment_asset on Asset { + __typename + ${getSlotFragmentSpreads(slots, 'asset')} + } + `, + comment: gql` + fragment TalkFeaturedComments_Comment_comment on Comment { + id + body + created_at + replyCount + tags { + tag { + name + } + } + user { + id + username + } + ${getSlotFragmentSpreads(slots, 'comment')} + } + ` +})(Comment); diff --git a/plugins/talk-plugin-featured-comments/client/containers/Tab.js b/plugins/talk-plugin-featured-comments/client/containers/Tab.js new file mode 100644 index 000000000..5d92387d6 --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/containers/Tab.js @@ -0,0 +1,15 @@ +import {compose, gql} from 'react-apollo'; +import {withFragments, excludeIf} from 'plugin-api/beta/client/hocs'; +import Tab from '../components/Tab'; + +const enhance = compose( + withFragments({ + asset: gql` + fragment TalkFeaturedComments_Tab_asset on Asset { + featuredCommentsCount: totalCommentCount(tags: ["FEATURED"], excludeIgnored: $excludeIgnored) + }`, + }), + excludeIf((props) => props.asset.featuredCommentsCount === 0), +); + +export default enhance(Tab); diff --git a/plugins/talk-plugin-featured-comments/client/containers/TabPane.js b/plugins/talk-plugin-featured-comments/client/containers/TabPane.js new file mode 100644 index 000000000..e9d466061 --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/containers/TabPane.js @@ -0,0 +1,98 @@ +import React from 'react'; +import {bindActionCreators} from 'redux'; +import {compose, gql} from 'react-apollo'; +import TabPane from '../components/TabPane'; +import {withFragments, connect} from 'plugin-api/beta/client/hocs'; +import Comment from '../containers/Comment'; +import {addNotification} from 'plugin-api/beta/client/actions/notification'; +import {viewComment} from 'coral-embed-stream/src/actions/stream'; +import {insertCommentsSorted, getDefinitionName} from 'plugin-api/beta/client/utils'; +import update from 'immutability-helper'; + +class TabPaneContainer extends React.Component { + + loadMore = () => { + return this.props.data.fetchMore({ + query: LOAD_MORE_QUERY, + variables: { + limit: 5, + cursor: this.props.root.asset.featuredComments.endCursor, + asset_id: this.props.root.asset.id, + sort: 'REVERSE_CHRONOLOGICAL', + excludeIgnored: this.props.data.variables.excludeIgnored, + }, + updateQuery: (previous, {fetchMoreResult:{comments}}) => { + const updated = update(previous, { + asset: { + featuredComments: { + nodes: { + $apply: (nodes) => insertCommentsSorted(nodes, comments.nodes, 'REVERSE_CHRONOLOGICAL'), + }, + hasNextPage: {$set: comments.hasNextPage}, + endCursor: {$set: comments.endCursor}, + }, + } + }); + return updated; + }, + }); + }; + + render() { + return ; + } +} + +const LOAD_MORE_QUERY = gql` + query CoralEmbedStream_LoadMoreComments($limit: Int = 5, $cursor: Date, $asset_id: ID, $sort: SORT_ORDER, $excludeIgnored: Boolean) { + comments(query: {limit: $limit, cursor: $cursor, tags: ["FEATURED"], asset_id: $asset_id, sort: $sort, excludeIgnored: $excludeIgnored}) { + nodes { + ...${getDefinitionName(Comment.fragments.comment)} + } + hasNextPage + startCursor + endCursor + } + } + ${Comment.fragments.comment} +`; + +const mapDispatchToProps = (dispatch) => + bindActionCreators({ + viewComment, + addNotification, + }, dispatch); + +const enhance = compose( + connect(null, mapDispatchToProps), + withFragments({ + root: gql` + fragment TalkFeaturedComments_TabPane_root on RootQuery { + __typename + ...${getDefinitionName(Comment.fragments.root)} + } + ${Comment.fragments.root} + `, + asset: gql` + fragment TalkFeaturedComments_TabPane_asset on Asset { + id + featuredComments: comments(tags: ["FEATURED"], excludeIgnored: $excludeIgnored, deep: true) { + nodes { + ...${getDefinitionName(Comment.fragments.comment)} + } + hasNextPage + startCursor + endCursor + } + ...${getDefinitionName(Comment.fragments.asset)} + } + ${Comment.fragments.comment} + ${Comment.fragments.asset} + `, + }), +); + +export default enhance(TabPaneContainer); diff --git a/plugins/talk-plugin-featured-comments/client/index.js b/plugins/talk-plugin-featured-comments/client/index.js new file mode 100644 index 000000000..d804e2b55 --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/index.js @@ -0,0 +1,94 @@ +import Tab from './containers/Tab'; +import TabPane from './containers/TabPane'; +import Tag from './components/Tag'; +import Button from './components/Button'; +import translations from './translations.yml'; +import update from 'immutability-helper'; + +import {findCommentInEmbedQuery} from 'coral-embed-stream/src/graphql/utils'; +import {insertCommentsSorted} from 'plugin-api/beta/client/utils'; + +export default { + translations, + slots: { + streamTabs: [Tab], + streamTabPanes: [TabPane], + commentInfoBar: [Tag], + commentReactions: [Button] + }, + mutations: { + IgnoreUser: ({variables}) => ({ + updateQueries: { + CoralEmbedStream_Embed: (previous) => { + const ignoredUserId = variables.id; + const newNodes = previous.asset.featuredComments.nodes.filter((n) => n.user.id !== ignoredUserId); + const removedCount = previous.asset.featuredComments.nodes.length - newNodes.length; + const updated = update(previous, { + asset: { + featuredComments: { + nodes: {$set: newNodes} + }, + featuredCommentsCount: { + $apply: (value) => value - removedCount, + } + } + }); + return updated; + } + } + }), + AddTag: ({variables}) => ({ + updateQueries: { + CoralEmbedStream_Embed: (previous) => { + + if (variables.name !== 'FEATURED') { + return; + } + + const comment = findCommentInEmbedQuery(previous, variables.id); + + const updated = update(previous, { + asset: { + featuredComments: { + nodes: { + $apply: (nodes) => insertCommentsSorted(nodes, comment, 'REVERSE_CHRONOLOGICAL') + } + }, + featuredCommentsCount: { + $apply: (value) => value + 1 + } + } + }); + + return updated; + }, + } + }), + RemoveTag: ({variables}) => ({ + updateQueries: { + CoralEmbedStream_Embed: (previous) => { + + if (variables.name !== 'FEATURED') { + return; + } + + const updated = update(previous, { + asset: { + featuredComments: { + nodes: { + $apply: (nodes) => + nodes.filter((n) => n.id !== variables.id) + } + }, + featuredCommentsCount: { + $apply: (value) => value - 1 + } + } + }); + + return updated; + }, + } + }) + }, +}; diff --git a/plugins/talk-plugin-featured-comments/client/translations.yml b/plugins/talk-plugin-featured-comments/client/translations.yml new file mode 100644 index 000000000..7f6db9370 --- /dev/null +++ b/plugins/talk-plugin-featured-comments/client/translations.yml @@ -0,0 +1,8 @@ +en: + talk-plugin-featured-comments: + featured: Featured + go_to_conversation: Go to conversation +es: + talk-plugin-featured-comments: + featured: Remarcado + go_to_conversation: Ir al comentario diff --git a/plugins/talk-plugin-featured-comments/index.js b/plugins/talk-plugin-featured-comments/index.js new file mode 100644 index 000000000..88f8344ae --- /dev/null +++ b/plugins/talk-plugin-featured-comments/index.js @@ -0,0 +1,14 @@ +module.exports = { + tags: [ + { + name: 'FEATURED', + permissions: { + public: true, + self: true, + roles: [] + }, + models: ['COMMENTS'], + created_at: new Date() + } + ] +}; diff --git a/plugins/talk-plugin-featured-comments/package.json b/plugins/talk-plugin-featured-comments/package.json new file mode 100644 index 000000000..9dabc6d0d --- /dev/null +++ b/plugins/talk-plugin-featured-comments/package.json @@ -0,0 +1,9 @@ +{ + "name": "@coralproject/talk-plugin-featured-comments", + "pluginName": "talk-plugin-featured-comments", + "version": "0.0.1", + "description": "Provides support for featured comments", + "main": "index.js", + "author": "The Coral Project Team ", + "license": "Apache-2.0" +} diff --git a/plugins/talk-plugin-featured-comments/yarn.lock b/plugins/talk-plugin-featured-comments/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/plugins/talk-plugin-featured-comments/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + diff --git a/plugins/talk-plugin-featured/client/components/Tab.js b/plugins/talk-plugin-featured/client/components/Tab.js deleted file mode 100644 index af11368b9..000000000 --- a/plugins/talk-plugin-featured/client/components/Tab.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import {TabCount} from 'plugin-api/beta/client/components/ui'; - -// TODO: This is just example code, and needs to replaced by an actual implementation. -export default ({active, asset: {recentComments}}) => ( - - Featured {recentComments.length} - -); diff --git a/plugins/talk-plugin-featured/client/components/TabPane.js b/plugins/talk-plugin-featured/client/components/TabPane.js deleted file mode 100644 index 107c806b4..000000000 --- a/plugins/talk-plugin-featured/client/components/TabPane.js +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; - -// TODO: This is just example code, and needs to replaced by an actual implementation. -export default ({asset: {recentComments}}) => ( -
- {recentComments.map((comment) => ( -
-
{comment.user.username}
-
- {comment.body} -
-
-
- ))} -
-); diff --git a/plugins/talk-plugin-featured/client/containers/Tab.js b/plugins/talk-plugin-featured/client/containers/Tab.js deleted file mode 100644 index 0bcb49e13..000000000 --- a/plugins/talk-plugin-featured/client/containers/Tab.js +++ /dev/null @@ -1,17 +0,0 @@ -import {compose, gql} from 'react-apollo'; -import withFragments from 'coral-framework/hocs/withFragments'; -import Tab from '../components/Tab'; - -// TODO: This is just example code, and needs to replaced by an actual implementation. -const enhance = compose( - withFragments({ - asset: gql` - fragment TalkFeatured_Tab_asset on Asset { - recentComments { - id - } - }`, - }), -); - -export default enhance(Tab); diff --git a/plugins/talk-plugin-featured/client/containers/TabPane.js b/plugins/talk-plugin-featured/client/containers/TabPane.js deleted file mode 100644 index 19c4d855d..000000000 --- a/plugins/talk-plugin-featured/client/containers/TabPane.js +++ /dev/null @@ -1,22 +0,0 @@ -import {compose, gql} from 'react-apollo'; -import withFragments from 'coral-framework/hocs/withFragments'; -import TabPane from '../components/TabPane'; - -// TODO: This is just example code, and needs to replaced by an actual implementation. -const enhance = compose( - withFragments({ - asset: gql` - fragment TalkFeatured_TabPane_asset on Asset { - recentComments { - id - body - user { - id - username - } - } - }`, - }), -); - -export default enhance(TabPane); diff --git a/plugins/talk-plugin-featured/client/index.js b/plugins/talk-plugin-featured/client/index.js deleted file mode 100644 index 69dfaa6cb..000000000 --- a/plugins/talk-plugin-featured/client/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import Tab from './containers/Tab'; -import TabPane from './containers/TabPane'; - -export default { - slots: { - streamTabs: [Tab], - streamTabPanes: [TabPane], - } -}; diff --git a/plugins/talk-plugin-featured/index.js b/plugins/talk-plugin-featured/index.js deleted file mode 100644 index 85dfb349b..000000000 --- a/plugins/talk-plugin-featured/index.js +++ /dev/null @@ -1,2 +0,0 @@ -module.exports = {}; - diff --git a/test/e2e/pages/embedStreamPage.js b/test/e2e/pages/embedStreamPage.js index 2d59d5e57..536e58b38 100644 --- a/test/e2e/pages/embedStreamPage.js +++ b/test/e2e/pages/embedStreamPage.js @@ -160,12 +160,6 @@ module.exports = { }, registerButton: { selector: '#signInDialog #coralRegister' - }, - setBestButton: { - selector: '.e2e__set-best-comment' - }, - unsetBestButton: { - selector: '.e2e__unset-best-comment' } } }; diff --git a/test/e2e/tests/Commenter/BestCommentTest.js b/test/e2e/tests/Commenter/BestCommentTest.js deleted file mode 100644 index 37e396149..000000000 --- a/test/e2e/tests/Commenter/BestCommentTest.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = { - '@tags': ['like', 'comments', 'commenter'], - before: (client) => { - const embedStreamPage = client.page.embedStreamPage(); - const {users} = client.globals; - - embedStreamPage - .navigate() - .ready(); - - embedStreamPage - .login(users.commenter); - }, - 'Commenters should not see the set-best-comment button': (client) => { - const embedStreamPage = client.page.embedStreamPage(); - - embedStreamPage - .postComment('Hi everyone. Isn\'t this the BEST comment!?') - .waitForElementVisible('@likeButton') - .expect.element('@setBestButton').to.not.be.present; - }, - after: (client) => { - client.end(); - } -}; diff --git a/test/e2e/tests/Moderator/BestCommentTest.js b/test/e2e/tests/Moderator/BestCommentTest.js deleted file mode 100644 index 5cdc2e5ed..000000000 --- a/test/e2e/tests/Moderator/BestCommentTest.js +++ /dev/null @@ -1,50 +0,0 @@ -module.exports = { - '@tags': ['like', 'comments', 'commenter'], - before: (client) => { - const embedStreamPage = client.page.embedStreamPage(); - const {users} = client.globals; - - embedStreamPage - .navigate() - .ready(); - - embedStreamPage - .login(users.moderator); - }, - 'Moderator marks/unmarks their comment as BEST': (client) => { - const embedStreamPage = client.page.embedStreamPage(); - - const setBestCommentButton = '.e2e__set-best-comment'; - const unsetBestCommentButton = '.e2e__unset-best-comment'; - - embedStreamPage - .postComment(`Hi everyone. Isn't this the BEST comment!? ${String(Math.random()).slice(2)}`) - .waitForElementVisible(setBestCommentButton, 2000) - .click(setBestCommentButton) - .waitForElementVisible(unsetBestCommentButton, 2000); - - // on refresh, it should still be tagged as best :) - client.refresh(); - embedStreamPage.ready() - - // (bengo) I have no idea why, but if the selector here is '@unsetBestButton', it doesn't find it... I think nightwatch bug? - // this is why I am not using @elements. Advice appreciated. - .waitForElementVisible(unsetBestCommentButton, 2000); - - // now remove the best tag - embedStreamPage - .click(unsetBestCommentButton); - - embedStreamPage - .waitForElementVisible(setBestCommentButton, 2000); - - // on refresh it should still be untagged best - client.refresh(); - embedStreamPage.ready() - .waitForElementVisible(setBestCommentButton); - - }, - after: (client) => { - client.end(); - } -}; diff --git a/webpack.config.js b/webpack.config.js index 5c6655d17..1f4c16ee4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -109,7 +109,8 @@ const config = { }), new webpack.EnvironmentPlugin({ 'TALK_PLUGINS_JSON': '{}', - 'TALK_THREADING_LEVEL': '3' + 'TALK_THREADING_LEVEL': '3', + 'TALK_DEFAULT_STREAM_TAB': 'all', }) ], resolveLoader: { diff --git a/yarn.lock b/yarn.lock index b2b169db2..710b89fa2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1560,6 +1560,10 @@ check-types@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/check-types/-/check-types-1.4.0.tgz#eed63bbac9ea49a0e26a096314058b03b08dd62b" +check-valid-url@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/check-valid-url/-/check-valid-url-0.0.2.tgz#938fc545fc90b71edf800f7345bfd36f3aa0a057" + cheerio@^0.20.0: version "0.20.0" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.20.0.tgz#5c710f2bab95653272842ba01c6ea61b3545ec35" @@ -4628,10 +4632,6 @@ json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: dependencies: jsonify "~0.0.0" -json-stringify-pretty-compact@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-1.0.4.tgz#d5161131be27fd9748391360597fcca250c6c5ce" - json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -6806,6 +6806,12 @@ query-string@^4.1.0, query-string@^4.2.2: object-assign "^4.1.0" strict-uri-encode "^1.0.0" +query-strings@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/query-strings/-/query-strings-0.0.1.tgz#d22bab97c9d39e2267b3b8e5f78592424b3e58cd" + dependencies: + check-valid-url "0.0.2" + querystring-es3@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"