Merge branch '1017-send-out-notifications-on-create-omment' of https://github.com/Human-Connection/Human-Connection into 1062-notification-about-comment-on-post

# Conflicts:
#	backend/src/middleware/handleNotifications/handleNotifications.js
#	backend/src/middleware/handleNotifications/handleNotifications.spec.js
#	webapp/components/notifications/Notification/Notification.vue
#	webapp/components/notifications/NotificationMenu/NotificationMenu.vue
This commit is contained in:
Wolfgang Huß 2019-08-19 09:18:43 +02:00
commit aae5fd396e
12 changed files with 295 additions and 188 deletions

View File

@ -15,16 +15,27 @@ const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
const session = context.driver.session() const session = context.driver.session()
const createdAt = new Date().toISOString() const createdAt = new Date().toISOString()
const cypher = ` let cypher
MATCH (source) if (label === 'Post') {
WHERE source.id = $id AND $label IN LABELS(source) cypher = `
MATCH (source)<-[:WROTE]-(author: User) MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
MATCH (u: User) MATCH (user: User)
WHERE u.id in $idsOfUsers WHERE user.id in $idsOfUsers
AND NOT (u)<-[:BLOCKED]-(author) AND NOT (user)<-[:BLOCKED]-(author)
CREATE (n: Notification { id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt }) CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
MERGE (source)-[:NOTIFIED]->(n)-[:NOTIFIED]->(u) MERGE (post)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
` `
} else {
cypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (user)<-[:BLOCKED]-(postAuthor)
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
`
}
await session.run(cypher, { await session.run(cypher, {
label, label,
id, id,
@ -65,12 +76,9 @@ const updateHashtagsOfPost = async (postId, hashtags, context) => {
} }
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
// extract user ids before xss-middleware removes classes via the following "resolve" call
const idsOfUsers = extractMentionedUsers(args.content) const idsOfUsers = extractMentionedUsers(args.content)
// extract tag (hashtag) ids before xss-middleware removes classes via the following "resolve" call
const hashtags = extractHashtags(args.content) const hashtags = extractHashtags(args.content)
// removes classes from the content
const post = await resolve(root, args, context, resolveInfo) const post = await resolve(root, args, context, resolveInfo)
await notifyUsers('Post', post.id, idsOfUsers, 'mentioned_in_post', context) await notifyUsers('Post', post.id, idsOfUsers, 'mentioned_in_post', context)
@ -80,10 +88,7 @@ const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo
} }
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => { const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
// extract user ids before xss-middleware removes classes via the following "resolve" call
const idsOfUsers = extractMentionedUsers(args.content) const idsOfUsers = extractMentionedUsers(args.content)
// removes classes from the content
const comment = await resolve(root, args, context, resolveInfo) const comment = await resolve(root, args, context, resolveInfo)
await notifyUsers('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context) await notifyUsers('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context)

View File

@ -60,6 +60,9 @@ describe('notifications', () => {
post { post {
content content
} }
comment {
content
}
} }
} }
} }
@ -71,12 +74,12 @@ describe('notifications', () => {
}) })
describe('given another user', () => { describe('given another user', () => {
let author let postAuthor
beforeEach(async () => { beforeEach(async () => {
author = await instance.create('User', { postAuthor = await instance.create('User', {
email: 'author@example.org', email: 'post-author@example.org',
password: '1234', password: '1234',
id: 'author', id: 'postAuthor',
}) })
}) })
@ -95,19 +98,19 @@ describe('notifications', () => {
} }
} }
` `
authenticatedUser = await author.toJson() authenticatedUser = await postAuthor.toJson()
await mutate({ await mutate({
mutation: createPostMutation, mutation: createPostMutation,
variables: { variables: {
id: 'p47', id: 'p47',
title, title,
content content,
}, },
}) })
authenticatedUser = await user.toJson() authenticatedUser = await user.toJson()
} }
it('sends you a notification', async () => { it.only('sends you a notification', async () => {
await createPostAction() await createPostAction()
const expectedContent = const expectedContent =
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?' 'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
@ -118,9 +121,10 @@ describe('notifications', () => {
read: false, read: false,
reason: 'mentioned_in_post', reason: 'mentioned_in_post',
post: { post: {
content: expectedContent content: expectedContent,
} },
}] comment: null,
}, ],
}, },
}, },
}) })
@ -131,8 +135,8 @@ describe('notifications', () => {
query({ query({
query: notificationQuery, query: notificationQuery,
variables: { variables: {
read: false read: false,
} },
}), }),
).resolves.toEqual(expected) ).resolves.toEqual(expected)
}) })
@ -161,7 +165,7 @@ describe('notifications', () => {
} }
} }
` `
authenticatedUser = await author.toJson() authenticatedUser = await postAuthor.toJson()
await mutate({ await mutate({
mutation: updatePostMutation, mutation: updatePostMutation,
variables: { variables: {
@ -185,15 +189,17 @@ describe('notifications', () => {
read: false, read: false,
reason: 'mentioned_in_post', reason: 'mentioned_in_post',
post: { post: {
content: expectedContent content: expectedContent,
} },
comment: null,
}, },
{ {
read: false, read: false,
reason: 'mentioned_in_post', reason: 'mentioned_in_post',
post: { post: {
content: expectedContent content: expectedContent,
} },
comment: null,
}, },
], ],
}, },
@ -203,8 +209,8 @@ describe('notifications', () => {
query({ query({
query: notificationQuery, query: notificationQuery,
variables: { variables: {
read: false read: false,
} },
}), }),
).resolves.toEqual(expected) ).resolves.toEqual(expected)
}) })
@ -212,7 +218,7 @@ describe('notifications', () => {
describe('but the author of the post blocked me', () => { describe('but the author of the post blocked me', () => {
beforeEach(async () => { beforeEach(async () => {
await author.relateTo(user, 'blocked') await postAuthor.relateTo(user, 'blocked')
}) })
it('sends no notification', async () => { it('sends no notification', async () => {
@ -220,8 +226,8 @@ describe('notifications', () => {
const expected = expect.objectContaining({ const expected = expect.objectContaining({
data: { data: {
currentUser: { currentUser: {
notifications: [] notifications: [],
} },
}, },
}) })
const { const {
@ -231,8 +237,66 @@ describe('notifications', () => {
query({ query({
query: notificationQuery, query: notificationQuery,
variables: { variables: {
read: false read: false,
},
}),
).resolves.toEqual(expected)
})
})
describe('but the author of the post blocked me and a mentioner mentions me in a comment', () => {
const createCommentOnPostAction = async () => {
await createPostAction()
const createCommentMutation = gql `
mutation($id: ID, $postId: ID!, $commentContent: String!) {
CreateComment(id: $id, postId: $postId, content: $commentContent) {
id
content
} }
}
`
authenticatedUser = await commentMentioner.toJson()
await mutate({
mutation: createCommentMutation,
variables: {
id: 'c47',
postId: 'p47',
commentContent: 'One mention of me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">.',
},
})
authenticatedUser = await user.toJson()
}
let commentMentioner
beforeEach(async () => {
await postAuthor.relateTo(user, 'blocked')
commentMentioner = await instance.create('User', {
id: 'mentioner',
name: 'Mr Mentioner',
slug: 'mr-mentioner',
email: 'mentioner@example.org',
password: '1234',
})
})
it('sends no notification', async () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
currentUser: {
notifications: [],
},
},
})
const {
query
} = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}), }),
).resolves.toEqual(expected) ).resolves.toEqual(expected)
}) })
@ -287,11 +351,15 @@ describe('Hashtags', () => {
}) })
it('both Hashtags are created with the "id" set to their "name"', async () => { it('both Hashtags are created with the "id" set to their "name"', async () => {
const expected = [{ id: 'Democracy' }, { id: 'Liberty' }] const expected = [{
id: 'Democracy'
}, {
id: 'Liberty'
}]
await expect( await expect(
query({ query({
query: postWithHastagsQuery, query: postWithHastagsQuery,
variables: postWithHastagsVariables variables: postWithHastagsVariables,
}), }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
@ -328,18 +396,22 @@ describe('Hashtags', () => {
}, },
}) })
const expected = [{ id: 'Elections' }, { id: 'Liberty' }] const expected = [{
id: 'Elections'
}, {
id: 'Liberty'
}]
await expect( await expect(
query({ query({
query: postWithHastagsQuery, query: postWithHastagsQuery,
variables: postWithHastagsVariables variables: postWithHastagsVariables,
}), }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
data: { data: {
Post: [{ Post: [{
tags: expect.arrayContaining(expected) tags: expect.arrayContaining(expected),
}], }, ],
}, },
}), }),
) )

View File

@ -3,17 +3,19 @@
<no-ssr> <no-ssr>
<ds-space margin-bottom="x-small"> <ds-space margin-bottom="x-small">
<hc-user <hc-user
:user="post.author || comment.author" v-if="resourceType == 'Post'"
:date-time="post.createdAt || comment.createdAt" :user="post.author"
:date-time="post.createdAt"
:trunc="35" :trunc="35"
/> />
<hc-user v-else :user="comment.author" :date-time="comment.createdAt" :trunc="35" />
</ds-space> </ds-space>
<ds-text color="soft">{{ $t(notificationTextIdents[notification.reason]) }}</ds-text> <ds-text color="soft">{{ $t(notificationTextIdents[notification.reason]) }}</ds-text>
</no-ssr> </no-ssr>
<ds-space margin-bottom="x-small" /> <ds-space margin-bottom="x-small" />
<nuxt-link <nuxt-link
class="notification-mention-post" class="notification-mention-post"
:to="{ name: 'post-id-slug', params: postParams, ...hashParam }" :to="{ name: 'post-id-slug', params, ...hashParam }"
@click.native="$emit('read')" @click.native="$emit('read')"
> >
<ds-space margin-bottom="x-small"> <ds-space margin-bottom="x-small">
@ -24,9 +26,14 @@
class="notifications-card" class="notifications-card"
> >
<ds-space margin-bottom="x-small" /> <ds-space margin-bottom="x-small" />
<!-- eslint-disable vue/no-v-html --> <div v-if="resourceType == 'Post'">{{ post.contentExcerpt | removeHtml }}</div>
<div v-html="excerpt" /> <div v-else>
<!-- eslint-enable vue/no-v-html --> <b>
Comment:
<nbsp />
</b>
{{ comment.contentExcerpt | removeHtml }}
</div>
</ds-card> </ds-card>
</ds-space> </ds-space>
</nuxt-link> </nuxt-link>
@ -57,11 +64,8 @@ export default {
} }
}, },
computed: { computed: {
excerpt() { resourceType() {
const excerpt = this.post.id ? this.post.contentExcerpt : this.comment.contentExcerpt return this.post.id ? 'Post' : 'Comment'
return (
(!this.post.id ? '<b>Comment: </b>' : '') + excerpt.replace(/<(?:.|\n)*?>/gm, '').trim()
)
}, },
post() { post() {
return this.notification.post || {} return this.notification.post || {}
@ -69,7 +73,7 @@ export default {
comment() { comment() {
return this.notification.comment || {} return this.notification.comment || {}
}, },
postParams() { params() {
return { return {
id: this.post.id || this.comment.post.id, id: this.post.id || this.comment.post.id,
slug: this.post.slug || this.comment.post.slug, slug: this.post.slug || this.comment.post.slug,

View File

@ -1,5 +1,5 @@
import { config, shallowMount, createLocalVue } from '@vue/test-utils' import { config, shallowMount, createLocalVue } from '@vue/test-utils'
import NotificationMenu from '.' import NotificationMenu from './NotificationMenu'
import Styleguide from '@human-connection/styleguide' import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters' import Filters from '~/plugins/vue-filters'

View File

@ -17,81 +17,9 @@
</template> </template>
<script> <script>
import NotificationList from '../NotificationList/NotificationList'
import Dropdown from '~/components/Dropdown' import Dropdown from '~/components/Dropdown'
import gql from 'graphql-tag' import { currentUserNotificationsQuery, updateNotificationMutation } from '~/graphql/User'
import NotificationList from '../NotificationList/NotificationList'
const MARK_AS_READ = gql`
mutation($id: ID!, $read: Boolean!) {
UpdateNotification(id: $id, read: $read) {
id
read
}
}
`
const NOTIFICATIONS = gql`
{
currentUser {
id
notifications(read: false, orderBy: createdAt_desc) {
id
read
reason
createdAt
post {
id
createdAt
disabled
deleted
title
contentExcerpt
slug
author {
id
slug
name
disabled
deleted
avatar
}
}
comment {
id
createdAt
disabled
deleted
contentExcerpt
author {
id
slug
name
disabled
deleted
avatar
}
post {
id
createdAt
disabled
deleted
title
contentExcerpt
slug
author {
id
slug
name
disabled
deleted
avatar
}
}
}
}
}
}
`
export default { export default {
name: 'NotificationMenu', name: 'NotificationMenu',
@ -112,7 +40,7 @@ export default {
const variables = { id: notificationId, read: true } const variables = { id: notificationId, read: true }
try { try {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: MARK_AS_READ, mutation: updateNotificationMutation(),
variables, variables,
}) })
} catch (err) { } catch (err) {
@ -122,7 +50,7 @@ export default {
}, },
apollo: { apollo: {
notifications: { notifications: {
query: NOTIFICATIONS, query: currentUserNotificationsQuery(),
update: data => { update: data => {
const { const {
currentUser: { notifications }, currentUser: { notifications },

View File

@ -75,3 +75,78 @@ export default i18n => {
} }
` `
} }
export const currentUserNotificationsQuery = () => {
return gql`
{
currentUser {
id
notifications(read: false, orderBy: createdAt_desc) {
id
read
createdAt
post {
id
createdAt
disabled
deleted
title
contentExcerpt
slug
author {
id
slug
name
disabled
deleted
avatar
}
}
comment {
id
createdAt
disabled
deleted
contentExcerpt
author {
id
slug
name
disabled
deleted
avatar
}
post {
id
createdAt
disabled
deleted
title
contentExcerpt
slug
author {
id
slug
name
disabled
deleted
avatar
}
}
}
}
}
}
`
}
export const updateNotificationMutation = () => {
return gql`
mutation($id: ID!, $read: Boolean!) {
UpdateNotification(id: $id, read: $read) {
id
read
}
}
`
}

View File

@ -151,7 +151,7 @@ import { mapGetters, mapActions, mapMutations } from 'vuex'
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch' import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
import SearchInput from '~/components/SearchInput.vue' import SearchInput from '~/components/SearchInput.vue'
import Modal from '~/components/Modal' import Modal from '~/components/Modal'
import NotificationMenu from '~/components/notifications/NotificationMenu' import NotificationMenu from '~/components/notifications/NotificationMenu/NotificationMenu'
import Dropdown from '~/components/Dropdown' import Dropdown from '~/components/Dropdown'
import HcAvatar from '~/components/Avatar/Avatar.vue' import HcAvatar from '~/components/Avatar/Avatar.vue'
import seo from '~/mixins/seo' import seo from '~/mixins/seo'

View File

@ -110,12 +110,10 @@ export default {
} }
}, },
data() { data() {
// Wolle: const { commentId = null } = this.$route.query
return { return {
post: null, post: null,
ready: false, ready: false,
title: 'loading', title: 'loading',
// Wolle: commentId,
} }
}, },
watch: { watch: {

View File

@ -255,7 +255,7 @@ import ContentMenu from '~/components/ContentMenu'
import HcUpload from '~/components/Upload' import HcUpload from '~/components/Upload'
import HcAvatar from '~/components/Avatar/Avatar.vue' import HcAvatar from '~/components/Avatar/Avatar.vue'
import PostQuery from '~/graphql/UserProfile/Post.js' import PostQuery from '~/graphql/UserProfile/Post.js'
import UserQuery from '~/graphql/UserProfile/User.js' import UserQuery from '~/graphql/User.js'
import { Block, Unblock } from '~/graphql/settings/BlockedUsers.js' import { Block, Unblock } from '~/graphql/settings/BlockedUsers.js'
const tabToFilterMapping = ({ tab, id }) => { const tabToFilterMapping = ({ tab, id }) => {

View File

@ -83,6 +83,15 @@ export default ({ app = {} }) => {
return excerpt return excerpt
}, },
removeHtml: content => {
if (!content) return ''
// replace linebreaks with spaces first
let contentExcerpt = content.replace(/<br>/gim, ' ').trim()
// remove the rest of the HTML
contentExcerpt = contentExcerpt.replace(/<(?:.|\n)*?>/gm, '').trim()
return contentExcerpt
},
proxyApiUrl: url => { proxyApiUrl: url => {
if (!url) return url if (!url) return url
return url.startsWith('/') ? url.replace('/', '/api/') : url return url.startsWith('/') ? url.replace('/', '/api/') : url

View File

@ -69,41 +69,43 @@ export const actions = {
const { const {
data: { currentUser }, data: { currentUser },
} = await client.query({ } = await client.query({
query: gql(`{ query: gql`
currentUser { {
id currentUser {
name
slug
email
avatar
role
about
locationName
contributionsCount
commentsCount
socialMedia {
id id
url name
} slug
notifications(read: false, orderBy: createdAt_desc) { email
id avatar
read role
createdAt about
post { locationName
author { contributionsCount
id commentsCount
socialMedia {
id
url
}
notifications(read: false, orderBy: createdAt_desc) {
id
read
createdAt
post {
author {
id
slug
name
disabled
deleted
}
title
contentExcerpt
slug slug
name
disabled
deleted
} }
title
contentExcerpt
slug
} }
} }
} }
}`), `,
}) })
if (!currentUser) return dispatch('logout') if (!currentUser) return dispatch('logout')
commit('SET_USER', currentUser) commit('SET_USER', currentUser)
@ -122,7 +124,10 @@ export const actions = {
login(email: $email, password: $password) login(email: $email, password: $password)
} }
`), `),
variables: { email, password }, variables: {
email,
password,
},
}) })
await this.app.$apolloHelpers.onLogin(login) await this.app.$apolloHelpers.onLogin(login)
commit('SET_TOKEN', login) commit('SET_TOKEN', login)

View File

@ -19,7 +19,10 @@ export const mutations = {
const toBeUpdated = notifications.find(n => { const toBeUpdated = notifications.find(n => {
return n.id === notification.id return n.id === notification.id
}) })
state.notifications = { ...toBeUpdated, ...notification } state.notifications = {
...toBeUpdated,
...notification,
}
}, },
} }
export const getters = { export const getters = {
@ -38,28 +41,30 @@ export const actions = {
const { const {
data: { currentUser }, data: { currentUser },
} = await client.query({ } = await client.query({
query: gql(`{ query: gql`
currentUser { {
id currentUser {
notifications(orderBy: createdAt_desc) {
id id
read notifications(orderBy: createdAt_desc) {
createdAt id
post { read
author { createdAt
id post {
author {
id
slug
name
disabled
deleted
}
title
contentExcerpt
slug slug
name
disabled
deleted
} }
title
contentExcerpt
slug
} }
} }
} }
}`), `,
}) })
notifications = currentUser.notifications notifications = currentUser.notifications
commit('SET_NOTIFICATIONS', notifications) commit('SET_NOTIFICATIONS', notifications)
@ -71,18 +76,24 @@ export const actions = {
async markAsRead({ commit, rootGetters }, notificationId) { async markAsRead({ commit, rootGetters }, notificationId) {
const client = this.app.apolloProvider.defaultClient const client = this.app.apolloProvider.defaultClient
const mutation = gql(` const mutation = gql`
mutation($id: ID!, $read: Boolean!) { mutation($id: ID!, $read: Boolean!) {
UpdateNotification(id: $id, read: $read) { UpdateNotification(id: $id, read: $read) {
id id
read read
} }
} }
`) `
const variables = { id: notificationId, read: true } const variables = {
id: notificationId,
read: true,
}
const { const {
data: { UpdateNotification }, data: { UpdateNotification },
} = await client.mutate({ mutation, variables }) } = await client.mutate({
mutation,
variables,
})
commit('UPDATE_NOTIFICATIONS', UpdateNotification) commit('UPDATE_NOTIFICATIONS', UpdateNotification)
}, },
} }