Merge branch 'master' of github.com:coralproject/talk

This commit is contained in:
Belen Curcio
2017-07-20 11:45:54 -03:00
64 changed files with 1210 additions and 716 deletions
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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/*
+31 -20
View File
@@ -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
@@ -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}
@@ -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 {
}
@@ -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 (
<div
className={rootClassName}
@@ -434,196 +397,175 @@ export default class Comment extends React.Component {
<Slot
className={styles.commentAvatar}
fill="commentAvatar"
comment={comment}
commentId={comment.id}
data={this.props.data}
root={this.props.root}
{...slotProps}
inline
/>
<div className={styles.commentContainer}>
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
{commentIsBest(comment)
? <TagLabel><BestIndicator /></TagLabel>
: null }
<div className={styles.header}>
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
</span>
<Slot
className={styles.commentInfoBar}
fill="commentInfoBar"
depth={depth}
comment={comment}
commentId={comment.id}
data={this.props.data}
root={this.props.root}
inline
/>
{ (currentUser && (comment.user.id === currentUser.id)) &&
/* User can edit/delete their own comment for a short window after posting */
<span className={cn(styles.topRight)}>
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
{
commentIsStillEditable(comment) &&
<a
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
onClick={this.onClickEdit}>Edit</a>
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
</span>
}
{ (currentUser && (comment.user.id !== currentUser.id)) &&
/* TopRightMenu allows currentUser to ignore other users' comments */
<span className={cn(styles.topRight, styles.topRightMenu)}>
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification} />
<Slot
className={styles.commentInfoBar}
fill="commentInfoBar"
{...slotProps}
/>
{ (currentUser && (comment.user.id === currentUser.id)) &&
/* User can edit/delete their own comment for a short window after posting */
<span className={cn(styles.topRight)}>
{
commentIsStillEditable(comment) &&
<a
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
onClick={this.onClickEdit}>Edit</a>
}
</span>
}
{
this.state.isEditing
? <EditableCommentContent
editComment={this.editComment}
addNotification={addNotification}
comment={comment}
currentUser={currentUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
parentId={parentId}
stopEditing={this.stopEditing}
/>
: <div>
<Slot fill="commentContent" comment={comment} defaultComponent={CommentContent} />
</div>
}
}
{ (currentUser && (comment.user.id !== currentUser.id)) &&
<div className="commentActionsLeft comment__action-container">
<Slot
fill="commentReactions"
data={this.props.data}
root={this.props.root}
comment={comment}
commentId={comment.id}
inline
/>
<ActionButton>
<IfUserCanModifyBest user={currentUser}>
<BestButton
isBest={commentIsBest(comment)}
addBest={addBestTag}
removeBest={removeBestTag}
/>
</IfUserCanModifyBest>
</ActionButton>
{!disableReply &&
<ActionButton>
<ReplyButton
onClick={this.showReplyBox}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
/>
</ActionButton>}
/* TopRightMenu allows currentUser to ignore other users' comments */
<span className={cn(styles.topRight, styles.topRightMenu)}>
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification} />
</span>
}
</div>
<div className="commentActionsRight comment__action-container">
<Slot
fill="commentActions"
wrapperComponent={ActionButton}
data={this.props.data}
root={this.props.root}
asset={asset}
comment={comment}
commentId={comment.id}
inline
/>
<ActionButton>
<FlagComment
flaggedByCurrentUser={!!myFlag}
flag={myFlag}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
addNotification={addNotification}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser}
<div className={styles.content}>
{
this.state.isEditing
? <EditableCommentContent
editComment={this.editComment}
addNotification={addNotification}
comment={comment}
currentUser={currentUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
parentId={parentId}
stopEditing={this.stopEditing}
/>
: <div>
<Slot
fill="commentContent"
defaultComponent={CommentContent}
{...slotProps}
/>
</div>
}
</div>
<div className={styles.footer}>
<div className="commentActionsLeft comment__action-container">
<Slot
fill="commentReactions"
{...slotProps}
inline
/>
</ActionButton>
{!disableReply &&
<ActionButton>
<ReplyButton
onClick={this.showReplyBox}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
/>
</ActionButton>}
</div>
<div className="commentActionsRight comment__action-container">
<Slot
fill="commentActions"
wrapperComponent={ActionButton}
{...slotProps}
inline
/>
<ActionButton>
<FlagComment
flaggedByCurrentUser={!!myFlag}
flag={myFlag}
id={comment.id}
author_id={comment.user.id}
postFlag={postFlag}
addNotification={addNotification}
postDontAgree={postDontAgree}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser}
/>
</ActionButton>
</div>
</div>
</div>
</div>
{activeReplyBox === comment.id
? <ReplyBox
commentPostedHandler={() => {
setActiveReplyBox('');
}}
{activeReplyBox === comment.id
? <ReplyBox
commentPostedHandler={() => {
setActiveReplyBox('');
}}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
setActiveReplyBox={setActiveReplyBox}
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
addNotification={addNotification}
postComment={postComment}
currentUser={currentUser}
assetId={asset.id}
/>
: null}
<TransitionGroup>
{view.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
loadMore={loadMore}
ignoreUser={ignoreUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
setActiveReplyBox={setActiveReplyBox}
parentId={(depth < THREADING_LEVEL) ? comment.id : parentId}
addNotification={addNotification}
postComment={postComment}
currentUser={currentUser}
assetId={asset.id}
/>
: null}
<TransitionGroup>
{view.map((reply) => {
return commentIsIgnored(reply)
? <IgnoredCommentTombstone key={reply.id} />
: <Comment
data={this.props.data}
root={this.props.root}
setActiveReplyBox={setActiveReplyBox}
disableReply={disableReply}
activeReplyBox={activeReplyBox}
addNotification={addNotification}
parentId={comment.id}
postComment={postComment}
editComment={this.props.editComment}
depth={depth + 1}
asset={asset}
highlighted={highlighted}
currentUser={currentUser}
postFlag={postFlag}
deleteAction={deleteAction}
addTag={addTag}
removeTag={removeTag}
loadMore={loadMore}
ignoreUser={ignoreUser}
charCountEnable={charCountEnable}
maxCharCount={maxCharCount}
showSignInDialog={showSignInDialog}
commentIsIgnored={commentIsIgnored}
liveUpdates={liveUpdates}
reactKey={reply.id}
key={reply.id}
comment={reply}
/>;
})}
</TransitionGroup>
<div className="talk-load-more-replies">
<LoadMore
topLevel={false}
replyCount={moreRepliesCount}
moreComments={hasMoreComments}
loadMore={this.loadNewReplies}
loadingState={loadingState}
/>
showSignInDialog={showSignInDialog}
commentIsIgnored={commentIsIgnored}
liveUpdates={liveUpdates}
reactKey={reply.id}
key={reply.id}
comment={reply}
/>;
})}
</TransitionGroup>
<div className="talk-load-more-replies">
<LoadMore
topLevel={false}
replyCount={moreRepliesCount}
moreComments={hasMoreComments}
loadMore={this.loadNewReplies}
loadingState={loadingState}
/>
</div>
</div>
);
@@ -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 {
</div>
: <p>{asset.settings.closedMessage}</p>}
<Slot fill="stream" />
<Slot
fill="stream"
{...slotProps}
/>
{loggedIn && (
<ModerationLink
@@ -175,8 +199,6 @@ class Stream extends React.Component {
data={data}
root={root}
commentClassNames={commentClassNames}
addTag={addTag}
removeTag={removeTag}
ignoreUser={ignoreUser}
setActiveReplyBox={setActiveReplyBox}
activeReplyBox={activeReplyBox}
@@ -206,13 +228,16 @@ class Stream extends React.Component {
<div
className={cn('talk-stream-filter-wrapper', styles.filterWrapper)}
>
<Slot fill="streamFilter" />
<Slot
fill="streamFilter"
{...slotProps}
/>
</div>
<TabBar activeTab={activeStreamTab} onTabClick={setActiveStreamTab} sub>
{getSlotComponents('streamTabs', reduxState, streamTabProps).map((PluginComponent) => (
{this.getSlotComponents('streamTabs').map((PluginComponent) => (
<Tab tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...streamTabProps}
{...slotProps}
active={activeStreamTab === PluginComponent.talkPluginName}
/>
</Tab>
@@ -222,10 +247,10 @@ class Stream extends React.Component {
</Tab>
</TabBar>
<TabContent activeTab={activeStreamTab} sub>
{getSlotComponents('streamTabPanes', reduxState, streamTabProps).map((PluginComponent) => (
{this.getSlotComponents('streamTabPanes').map((PluginComponent) => (
<TabPane tabId={PluginComponent.talkPluginName} key={PluginComponent.talkPluginName}>
<PluginComponent
{...streamTabProps}
{...slotProps}
/>
</TabPane>
))}
@@ -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,
@@ -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;
@@ -27,9 +27,8 @@ export default class Toggleable extends React.Component {
const {isOpen} = this.state;
return (
<ClickOutside onClickOutside={this.close}>
<span className={styles.Toggleable} tabIndex="0" >
<span className={styles.toggler}
onClick={this.toggle}>{isOpen ? upArrow : downArrow}</span>
<span className={styles.Toggleable}>
<button className={styles.toggler} onClick={this.toggle}>{isOpen ? upArrow : downArrow}</button>
{isOpen ? children : null}
</span>
</ClickOutside>
@@ -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;
@@ -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
@@ -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);
}
}
@@ -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,
+4 -12
View File
@@ -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);
));
}},
},
});
@@ -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,
+41 -7
View File
@@ -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) {
+111 -92
View File
@@ -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!) {
+2 -2
View File
@@ -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 || {};
+32
View File
@@ -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`.
-14
View File
@@ -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;
}
-105
View File
@@ -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 = <Icon name='star' className={styles.tagIcon} />}) => (
<span aria-label={t('comment_is_best')}>
{ children }
</span>
);
/**
* 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 (
<button onClick={isBest ? this.onClickRemoveBest : this.onClickAddBest}
disabled={disabled}
className={cn(styles.button, `${name}-button`, `e2e__${isBest ? 'unset' : 'set'}-best-comment`)}
aria-label={t(isBest ? 'unset_best' : 'set_best')}>
<Icon name={ isBest ? 'star' : 'star_border' } className={styles.icon} />
</button>
);
}
}
-3
View File
@@ -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"
-3
View File
@@ -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"
+1
View File
@@ -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",
@@ -0,0 +1 @@
export {addNotification} from 'coral-framework/actions/notification';
+1
View File
@@ -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';
+111
View File
@@ -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 <WrappedComponent
user={this.props.user}
comment={comment}
alreadyTagged={alreadyTagged}
postTag={this.postTag}
deleteTag={this.deleteTag}
/>;
}
}
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);
};
+1 -1
View File
@@ -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';
+7 -1
View File
@@ -1 +1,7 @@
export {getSlotFragmentSpreads} from 'coral-framework/utils';
export {
isTagged,
insertCommentsSorted,
getSlotFragmentSpreads,
forEachError,
getDefinitionName,
} from 'coral-framework/utils';
+4 -2
View File
@@ -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"
]
}
@@ -9,6 +9,7 @@
padding: 0px;
border: none;
font-size: inherit;
vertical-align: middle;
&:hover {
color: #767676;
@@ -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) => (
<span>
{
isOffTopic(props.comment.tags) && props.depth === 0 ? (
isTagged(props.comment.tags, 'OFF_TOPIC') && props.depth === 0 ? (
<span className={styles.tag}>
{t('off_topic')}
</span>
@@ -9,6 +9,7 @@
padding: 0px;
border: none;
font-size: inherit;
vertical-align: middle;
&:hover {
color: #767676;
@@ -11,4 +11,4 @@
"transform-async-to-generator",
"transform-react-jsx"
]
}
}
@@ -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"
]
}
@@ -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;
}
@@ -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') ? (
<button
className={cn([`${pluginName}-tag-button`, styles.button, {[styles.featured] : alreadyTagged}])}
onClick={alreadyTagged ? deleteTag : postTag} >
{alreadyTagged ?
<Icon name="star" className={styles.icon} /> :
<Icon name="star_border" className={styles.icon} />
}
</button>
) : null ;
};
export default withTags('featured')(Button);
@@ -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;
}
@@ -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 (
<div className={cn(styles.root, `${pluginName}-comment`)}>
<blockquote className={cn(styles.quote, `${pluginName}-comment-body`)}>
{comment.body}
</blockquote>
<div className={cn(`${pluginName}-comment-username-box`)}>
<strong className={cn(styles.username, `${pluginName}-comment-username`)}>
{comment.user.username}
</strong>
<span className={cn(styles.timeago, `${pluginName}-comment-timeago`)}>
,{' '}{timeago(comment.created_at)}
</span>
</div>
<footer className={cn(styles.footer, `${pluginName}-comment-footer`)}>
<div className={cn(styles.reactionsContainer, `${pluginName}-comment-reactions`)}>
<Slot
fill="commentReactions"
root={root}
data={data}
comment={comment}
asset={asset}
inline
/>
</div>
<div className={cn(styles.actionsContainer, `${pluginName}-comment-actions`)}>
<button className={cn(styles.goTo, `${pluginName}-comment-go-to`)} onClick={this.viewComment}>
<Icon name="forum" className={styles.repliesIcon} /> {comment.replyCount} | {t('talk-plugin-featured-comments.go_to_conversation')} <Icon name="keyboard_arrow_right" className={styles.goToIcon} />
</button>
</div>
</footer>
</div>
);
}
}
export default Comment;
@@ -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 (
<div className='talk-load-more'>
<Button
onClick={loadMore}
className={cn('talk-load-more-button', {[`talk-load-more-button-${loadingState}`]: loadingState})}
disabled={disabled}
>
{t('framework.view_more_comments')}
</Button>
</div>
);
}
}
LoadMore.propTypes = {
loadMore: PropTypes.func.isRequired,
loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']),
};
export default LoadMore;
@@ -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}}) => (
<span>
{t('talk-plugin-featured-comments.featured')} <TabCount active={active} sub>{featuredCommentsCount}</TabCount>
</span>
);
@@ -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 (
<div>
{featuredComments.nodes.map((comment) =>
<Comment
key={comment.id}
root={root}
data={data}
comment={comment}
asset={asset}
viewComment={viewComment} />
)}
{featuredComments.hasNextPage &&
<LoadMore
loadMore={this.loadMore}
loadingState={this.loadingState}
/>
}
</div>
);
}
}
export default TabPane;
@@ -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;
}
@@ -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) => (
<span>
{
isTagged(props.comment.tags, 'FEATURED') && props.depth === 0 ? (
<span className={styles.tag}>
{t('talk-plugin-featured-comments.featured')}
</span>
) : null
}
</span>
);
@@ -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);
@@ -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);
@@ -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 <TabPane
{...this.props}
loadMore={this.loadMore}
/>;
}
}
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);
@@ -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;
},
}
})
},
};
@@ -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
@@ -0,0 +1,14 @@
module.exports = {
tags: [
{
name: 'FEATURED',
permissions: {
public: true,
self: true,
roles: []
},
models: ['COMMENTS'],
created_at: new Date()
}
]
};
@@ -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 <coral@mozillafoundation.org>",
"license": "Apache-2.0"
}
@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
@@ -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}}) => (
<span>
Featured <TabCount active={active} sub>{recentComments.length}</TabCount>
</span>
);
@@ -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}}) => (
<div>
{recentComments.map((comment) => (
<div key={comment.id}>
<div><strong>{comment.user.username}</strong></div>
<div>
{comment.body}
</div>
<hr />
</div>
))}
</div>
);
@@ -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);
@@ -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);
@@ -1,9 +0,0 @@
import Tab from './containers/Tab';
import TabPane from './containers/TabPane';
export default {
slots: {
streamTabs: [Tab],
streamTabPanes: [TabPane],
}
};
-2
View File
@@ -1,2 +0,0 @@
module.exports = {};
-6
View File
@@ -160,12 +160,6 @@ module.exports = {
},
registerButton: {
selector: '#signInDialog #coralRegister'
},
setBestButton: {
selector: '.e2e__set-best-comment'
},
unsetBestButton: {
selector: '.e2e__unset-best-comment'
}
}
};
@@ -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();
}
};
@@ -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();
}
};
+2 -1
View File
@@ -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: {
+10 -4
View File
@@ -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"