Support live comment edit update

This commit is contained in:
Chi Vinh Le
2017-06-15 22:46:38 +07:00
parent 5ecdfe94cc
commit 303b3ea0f5
15 changed files with 221 additions and 91 deletions
+24 -5
View File
@@ -90,10 +90,12 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac
const nextQueues = getCommentQueues(comment);
queues.forEach((queue) => {
if (nextQueues.indexOf(queue) >= 0 && !queueHasComment(next, queue, comment.id)) {
next = addCommentToQueue(next, queue, comment, sort);
if (notify && activeQueue === queue && isCommentInCursor(next, queue, comment, sort)) {
showNotification(queue, comment, user);
if (nextQueues.indexOf(queue) >= 0) {
if (!queueHasComment(next, queue, comment.id)) {
next = addCommentToQueue(next, queue, comment, sort);
if (notify && activeQueue === queue && isCommentInCursor(next, queue, comment, sort)) {
showNotification(queue, comment, user);
}
}
} else if(queueHasComment(next, queue, comment.id)){
next = removeCommentFromQueue(next, queue, comment.id);
@@ -102,10 +104,27 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac
}
}
// TODO: All notification
if (
queue === 'all'
&& queueHasComment(next, queue, comment.id)
&& notify
&& activeQueue === queue
) {
showNotification(queue, comment, user);
}
// TODO: Flagged notification
// TODO: Edited notification
});
return next;
}
export function handleCommentEdit(root, comment, {sort, activeQueue}) {
if (
queueHasComment(root, activeQueue, comment.id)
) {
const text = `${comment.user.username} edited comment to "${truncate(comment.body, 50)}"`;
notification.info(text);
}
return handleCommentStatusChange(root, comment, {sort, activeQueue});
}
@@ -13,6 +13,8 @@ import ActionButton from 'coral-admin/src/components/ActionButton';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import cn from 'classnames';
import {murmur3} from 'murmurhash-js';
import {CSSTransitionGroup} from 'react-transition-group';
const linkify = new Linkify();
@@ -91,6 +93,11 @@ class Comment extends React.Component {
<span className={styles.created}>
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
</span>
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>(Edited)</span></span>
: null
}
{props.currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
@@ -124,66 +131,81 @@ class Comment extends React.Component {
{!props.currentAsset &&
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
</div>
<div className={styles.itemBody}>
<p className={styles.body}>
<Highlighter
searchWords={searchWords}
textToHighlight={comment.body}
/>
{' '}
<a
className={styles.external}
href={`${comment.asset.url}#${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
</a>
</p>
<Slot
data={props.data}
root={props.root}
fill="adminCommentContent"
comment={comment}
/>
<div className={styles.sideActions}>
{links
? <span className={styles.hasLinks}>
<Icon name="error_outline" /> Contains Link
</span>
: null}
<div className={`actions ${styles.actions}`}>
{actions.map((action, i) => {
const active =
(action === 'REJECT' && comment.status === 'REJECTED') ||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return (
<ActionButton
minimal={minimal}
key={i}
type={action}
user={comment.user}
status={comment.status}
active={active}
acceptComment={() =>
(comment.status === 'ACCEPTED'
? null
: props.acceptComment({commentId: comment.id}))}
rejectComment={() =>
(comment.status === 'REJECTED'
? null
: props.rejectComment({commentId: comment.id}))}
/>
);
})}
</div>
<CSSTransitionGroup
component={'div'}
style={{position: 'relative'}}
transitionName={{
enter: styles.bodyEnter,
enterActive: styles.bodyEnterActive,
leave: styles.bodyLeave,
leaveActive: styles.bodyLeaveActive,
}}
transitionEnter={true}
transitionLeave={true}
transitionEnterTimeout={3600}
transitionLeaveTimeout={2800}
>
<div className={styles.itemBody} key={murmur3(comment.body)}>
<p className={styles.body}>
<Highlighter
searchWords={searchWords}
textToHighlight={comment.body}
/>
{' '}
<a
className={styles.external}
href={`${comment.asset.url}#${comment.id}`}
target="_blank"
>
<Icon name="open_in_new" /> {t('comment.view_context')}
</a>
</p>
<Slot
data={props.data}
root={props.root}
fill="adminSideActions"
fill="adminCommentContent"
comment={comment}
/>
<div className={styles.sideActions}>
{links
? <span className={styles.hasLinks}>
<Icon name="error_outline" /> Contains Link
</span>
: null}
<div className={`actions ${styles.actions}`}>
{actions.map((action, i) => {
const active =
(action === 'REJECT' && comment.status === 'REJECTED') ||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
return (
<ActionButton
minimal={minimal}
key={i}
type={action}
user={comment.user}
status={comment.status}
active={active}
acceptComment={() =>
(comment.status === 'ACCEPTED'
? null
: props.acceptComment({commentId: comment.id}))}
rejectComment={() =>
(comment.status === 'REJECTED'
? null
: props.rejectComment({commentId: comment.id}))}
/>
);
})}
</div>
<Slot
data={props.data}
root={props.root}
fill="adminSideActions"
comment={comment}
/>
</div>
</div>
</div>
</CSSTransitionGroup>
</div>
<Slot
data={props.data}
@@ -26,6 +26,14 @@ class ModerationQueue extends React.Component {
this.props.loadMore(this.props.activeTab);
}
constructor(props) {
super(props);
if (props.comments.length === 0 && props.commentCount > 0) {
this.loadMore();
}
}
componentDidUpdate (prev) {
const {comments, commentCount} = this.props;
@@ -66,8 +74,7 @@ class ModerationQueue extends React.Component {
transitionLeaveTimeout={1000}
>
{
comments.length
? comments.map((comment, i) => {
comments.map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
return <Comment
data={this.props.data}
@@ -88,9 +95,10 @@ class ModerationQueue extends React.Component {
currentUserId={this.props.currentUserId}
/>;
})
: <EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
}
</CSSTransitionGroup>
{comments.length === 0 && <p><EmptyCard>{t('modqueue.empty_queue')}</EmptyCard></p>}
<LoadMore
loadMore={this.loadMore}
showLoadMore={comments.length < commentCount}
@@ -497,3 +497,36 @@ span {
.commentEnterActive {
opacity: 1.0;
}
.bodyLeave {
position: absolute;
width: 100%;
top: 0;
background-color: white;
opacity: 1.0;
transition: background 400ms, opacity 800ms 1600ms;
pointer-events: none;
}
.bodyLeaveActive {
opacity: 0;
background-color: rgba(255,255,0, 0.2);
}
.bodyEnter {
opacity: 0;
pointer-events: none;
}
.bodyEnterActive {
opacity: 1.0;
transition: opacity 800ms 2400ms;
}
.editedMarker {
font-style: italic;
color: #666;
font-size: 12px;
line-height: 1px;
font-weight: 300;
}
@@ -49,6 +49,10 @@ export default withFragments({
}
}
}
editing {
edited
editableUntil
}
${pluginFragments.spreads('comment')}
}
${pluginFragments.definitions('comment')}
@@ -10,7 +10,7 @@ import t, {timeago} from 'coral-framework/services/i18n';
import update from 'immutability-helper';
import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {handleCommentStatusChange} from '../../../graphql/utils';
import {handleCommentStatusChange, handleCommentEdit} from '../../../graphql/utils';
import {fetchSettings} from 'actions/settings';
import {updateAssets} from 'actions/assets';
@@ -32,24 +32,23 @@ import Moderation from '../components/Moderation';
import Comment from './Comment';
class ModerationContainer extends Component {
unsubscribe = null;
subscriptions = [];
get activeTab() { return this.props.route.path === ':id' ? 'premod' : this.props.route.path; }
subscribeToUpdates() {
this.unsubscribe = this.props.data.subscribeToMore({
const sub1 = this.props.data.subscribeToMore({
document: STATUS_CHANGED_SUBSCRIPTION,
variables: {
asset_id: this.props.data.variables.asset_id,
},
updateQuery: (prev, {subscriptionData: {data: {commentStatusChanged: {user, comment, previous}}}}) => {
updateQuery: (prev, {subscriptionData: {data: {commentStatusChanged: {user, comment}}}}) => {
const extraParams = this.props.auth.user.id === user.id
? {}
: {
notify: true,
user,
activeQueue: this.activeTab,
previous,
};
return handleCommentStatusChange(prev, comment, {
sort: this.props.moderation.sortOrder,
@@ -57,14 +56,25 @@ class ModerationContainer extends Component {
});
},
});
const sub2 = this.props.data.subscribeToMore({
document: COMMENTS_EDITED_SUBSCRIPTION,
variables: {
asset_id: this.props.data.variables.asset_id,
},
updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => {
return handleCommentEdit(prev, commentEdited, {
activeQueue: this.activeTab,
});
},
});
this.subscriptions.push(sub1, sub2);
}
unsubscribe() {
if (!this.unsubscribe) {
return;
}
this.unsubscribe();
this.unsubscribe = null;
this.subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions = [];
}
resubscribe() {
@@ -196,6 +206,23 @@ class ModerationContainer extends Component {
}
}
const COMMENTS_EDITED_SUBSCRIPTION = gql`
subscription CommentEdited($asset_id: ID){
commentEdited(asset_id: $asset_id){
id
body
status
editing {
edited
}
user {
id
username
}
}
}
`;
const STATUS_CHANGED_SUBSCRIPTION = gql`
subscription CommentStatusChanged($asset_id: ID){
commentStatusChanged(asset_id: $asset_id){
@@ -224,9 +251,6 @@ const STATUS_CHANGED_SUBSCRIPTION = gql`
}
}
}
previous {
status
}
}
}
`;
-4
View File
@@ -370,11 +370,7 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
// Publish the edited comment via the subscription.
context.pubsub.publish('commentEdited', comment);
// Publish the comment status change via the subscription.
context.pubsub.publish('commentStatusChanged', comment);
}
return comment;
};
+7 -5
View File
@@ -31,14 +31,16 @@ const RootMutation = {
stopIgnoringUser(_, {id}, {mutators: {User}}) {
return wrapResponse(null)(User.stopIgnoringUser({id}));
},
setCommentStatus: async (_, {id, status}, {loaders: {Comments}, mutators: {Comment}, user, pubsub}) => {
const previous = await Comments.get.load(id);
setCommentStatus: async (_, {id, status}, {mutators: {Comment}, user, pubsub}) => {
const comment = await Comment.setStatus({id, status});
// Publish the comment status change via the subscription.
pubsub.publish('commentStatusChanged', {user, comment, previous});
if (pubsub) {
return wrapResponse(null)(Comment.setStatus({id, status}));
// Publish the comment status change via the subscription.
pubsub.publish('commentStatusChanged', {user, comment});
}
return wrapResponse(null)(comment);
},
addTag(_, {tag}, {mutators: {Tag}}) {
return wrapResponse(null)(Tag.add(tag));
+7 -1
View File
@@ -12,6 +12,7 @@ const {deserializeUser} = require('../services/subscriptions');
const {
SUBSCRIBE_COMMENT_STATUS,
SUBSCRIBE_ALL_COMMENT_EDITS,
} = require('../perms/constants');
/**
@@ -31,7 +32,12 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu
}),
commentEdited: (options, args) => ({
commentEdited: {
filter: (comment) => comment.asset_id === args.asset_id
filter: (comment, context) => {
if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_EDITS))) {
return false;
}
return !args.asset_id || comment.asset_id === args.asset_id;
}
},
}),
commentStatusChanged: (options, args) => ({
+1 -2
View File
@@ -940,12 +940,11 @@ type RootMutation {
type CommentStatusChangedUpdate {
user: User
comment: Comment
previous: Comment
}
type Subscription {
commentAdded(asset_id: ID!): Comment
commentEdited(asset_id: ID!): Comment
commentEdited(asset_id: ID): Comment
commentStatusChanged(asset_id: ID): CommentStatusChangedUpdate
}
+1
View File
@@ -98,6 +98,7 @@
"mongoose": "^4.9.8",
"morgan": "^1.8.1",
"ms": "^2.0.0",
"murmurhash-js": "^1.0.0",
"natural": "^0.5.0",
"node-emoji": "^1.5.1",
"node-fetch": "^1.6.3",
+2
View File
@@ -25,4 +25,6 @@ module.exports = {
// subscriptions
SUBSCRIBE_COMMENT_STATUS: 'SUBSCRIBE_COMMENT_STATUS',
SUBSCRIBE_ALL_COMMENT_FLAGS: 'SUBSCRIBE_ALL_COMMENT_FLAGS',
SUBSCRIBE_ALL_COMMENT_EDITS: 'SUBSCRIBE_ALL_COMMENT_EDITS',
};
+4
View File
@@ -5,6 +5,10 @@ module.exports = (user, perm) => {
switch (perm) {
case types.SUBSCRIBE_COMMENT_STATUS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_COMMENT_EDITS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_COMMENT_FLAGS:
return check(user, ['ADMIN', 'MODERATOR']);
default:
break;
}
+10 -4
View File
@@ -132,8 +132,11 @@ function getReactionConfig(reaction) {
return Action.create({item_id, item_type: 'COMMENTS', action_type: REACTION})
.then((action) => {
// The comment is needed to allow better filtering e.g. by asset_id.
pubsub.publish(`${reaction}ActionCreated`, {action, comment});
if (pubsub) {
// The comment is needed to allow better filtering e.g. by asset_id.
pubsub.publish(`${reaction}ActionCreated`, {action, comment});
}
return Promise.resolve(action);
})
.catch((err) => {
@@ -155,8 +158,11 @@ function getReactionConfig(reaction) {
}
return Comments.get.load(action.item_id).then((comment) => {
// The comment is needed to allow better filtering e.g. by asset_id.
pubsub.publish(`${reaction}ActionDeleted`, {action, comment});
if (pubsub) {
// The comment is needed to allow better filtering e.g. by asset_id.
pubsub.publish(`${reaction}ActionDeleted`, {action, comment});
}
return Promise.resolve(action);
});
});
+4
View File
@@ -5387,6 +5387,10 @@ muri@1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/muri/-/muri-1.2.1.tgz#ec7ea5ce6ca6a523eb1ab35bacda5fa816c9aa3c"
murmurhash-js@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz#b06278e21fc6c37fa5313732b0412bcb6ae15f51"
mute-stream@0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.4.tgz#a9219960a6d5d5d046597aee51252c6655f7177e"