mirror of
https://github.com/wassname/talk.git
synced 2026-07-11 15:00:41 +08:00
Merge branch 'master' of github.com:coralproject/talk
This commit is contained in:
+2
-1
@@ -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
@@ -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/*
|
||||
|
||||
@@ -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> <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> <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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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 || {};
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export {getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
export {
|
||||
isTagged,
|
||||
insertCommentsSorted,
|
||||
getSlotFragmentSpreads,
|
||||
forEachError,
|
||||
getDefinitionName,
|
||||
} from 'coral-framework/utils';
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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],
|
||||
}
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
module.exports = {};
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user