mirror of
https://github.com/wassname/talk.git
synced 2026-08-07 11:29:44 +08:00
Support live comment edit update
This commit is contained in:
@@ -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> <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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user