diff --git a/backend/package.json b/backend/package.json index 89fb87edf..1e4c2b8a1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -61,7 +61,7 @@ "dotenv": "~8.1.0", "express": "^4.17.1", "faker": "Marak/faker.js#master", - "graphql": "^14.5.0", + "graphql": "^14.5.3", "graphql-custom-directives": "~0.2.14", "graphql-iso-date": "~3.6.1", "graphql-middleware": "~3.0.5", diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.js b/backend/src/middleware/handleHtmlContent/handleContentData.js deleted file mode 100644 index 403b2044e..000000000 --- a/backend/src/middleware/handleHtmlContent/handleContentData.js +++ /dev/null @@ -1,96 +0,0 @@ -import extractMentionedUsers from './notifications/extractMentionedUsers' -import extractHashtags from './hashtags/extractHashtags' - -const notifyMentions = async (label, id, idsOfMentionedUsers, context) => { - if (!idsOfMentionedUsers.length) return - - const session = context.driver.session() - const createdAt = new Date().toISOString() - let cypher - if (label === 'Post') { - cypher = ` - MATCH (post: Post { id: $id })<-[:WROTE]-(author: User) - MATCH (user: User) - WHERE user.id in $idsOfMentionedUsers - AND NOT (user)<-[:BLOCKED]-(author) - CREATE (notification: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt }) - 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 $idsOfMentionedUsers - AND NOT (user)<-[:BLOCKED]-(author) - AND NOT (user)<-[:BLOCKED]-(postAuthor) - CREATE (notification: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt }) - MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user) - ` - } - await session.run(cypher, { - idsOfMentionedUsers, - label, - createdAt, - id, - }) - session.close() -} - -const updateHashtagsOfPost = async (postId, hashtags, context) => { - if (!hashtags.length) return - - const session = context.driver.session() - // We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement - // functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted - // and no new Hashtags and relations will be created. - const cypherDeletePreviousRelations = ` - MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag) - DELETE previousRelations - RETURN p, t - ` - const cypherCreateNewTagsAndRelations = ` - MATCH (p: Post { id: $postId}) - UNWIND $hashtags AS tagName - MERGE (t: Tag { id: tagName, disabled: false, deleted: false }) - MERGE (p)-[:TAGGED]->(t) - RETURN p, t - ` - await session.run(cypherDeletePreviousRelations, { - postId, - }) - await session.run(cypherCreateNewTagsAndRelations, { - postId, - hashtags, - }) - session.close() -} - -const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { - const idsOfMentionedUsers = extractMentionedUsers(args.content) - const hashtags = extractHashtags(args.content) - - const post = await resolve(root, args, context, resolveInfo) - - await notifyMentions('Post', post.id, idsOfMentionedUsers, context) - await updateHashtagsOfPost(post.id, hashtags, context) - - return post -} - -const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => { - const idsOfMentionedUsers = extractMentionedUsers(args.content) - const comment = await resolve(root, args, context, resolveInfo) - - await notifyMentions('Comment', comment.id, idsOfMentionedUsers, context) - - return comment -} - -export default { - Mutation: { - CreatePost: handleContentDataOfPost, - UpdatePost: handleContentDataOfPost, - CreateComment: handleContentDataOfComment, - UpdateComment: handleContentDataOfComment, - }, -} diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js deleted file mode 100644 index 2925f92cf..000000000 --- a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js +++ /dev/null @@ -1,392 +0,0 @@ -import { gql } from '../../jest/helpers' -import Factory from '../../seed/factories' -import { createTestClient } from 'apollo-server-testing' -import { neode, getDriver } from '../../bootstrap/neo4j' -import createServer from '../../server' - -let server -let query -let mutate -let user -let authenticatedUser -const factory = Factory() -const driver = getDriver() -const instance = neode() -const categoryIds = ['cat9'] -const createPostMutation = gql` - mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]!) { - CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { - id - title - content - } - } -` -const updatePostMutation = gql` - mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]!) { - UpdatePost(id: $id, content: $content, title: $title, categoryIds: $categoryIds) { - title - content - } - } -` - -beforeAll(() => { - const createServerResult = createServer({ - context: () => { - return { - user: authenticatedUser, - neode: instance, - driver, - } - }, - }) - server = createServerResult.server - const createTestClientResult = createTestClient(server) - query = createTestClientResult.query - mutate = createTestClientResult.mutate -}) - -beforeEach(async () => { - user = await instance.create('User', { - id: 'you', - name: 'Al Capone', - slug: 'al-capone', - email: 'test@example.org', - password: '1234', - }) - await instance.create('Category', { - id: 'cat9', - name: 'Democracy & Politics', - icon: 'university', - }) -}) - -afterEach(async () => { - await factory.cleanDatabase() -}) - -describe('notifications', () => { - const notificationQuery = gql` - query($read: Boolean) { - currentUser { - notifications(read: $read, orderBy: createdAt_desc) { - read - post { - content - } - comment { - content - } - } - } - } - ` - - describe('authenticated', () => { - beforeEach(async () => { - authenticatedUser = user - }) - - describe('given another user', () => { - let postAuthor - beforeEach(async () => { - postAuthor = await instance.create('User', { - email: 'post-author@example.org', - password: '1234', - id: 'postAuthor', - }) - }) - - describe('who mentions me in a post', () => { - const title = 'Mentioning Al Capone' - const content = - 'Hey @al-capone how do you do?' - - const createPostAction = async () => { - authenticatedUser = await postAuthor.toJson() - await mutate({ - mutation: createPostMutation, - variables: { id: 'p47', title, content, categoryIds }, - }) - authenticatedUser = await user.toJson() - } - - it('sends you a notification', async () => { - await createPostAction() - const expectedContent = - 'Hey @al-capone how do you do?' - const expected = expect.objectContaining({ - data: { - currentUser: { - notifications: [ - { - read: false, - post: { - content: expectedContent, - }, - comment: null, - }, - ], - }, - }, - }) - const { query } = createTestClient(server) - await expect( - query({ - query: notificationQuery, - variables: { - read: false, - }, - }), - ).resolves.toEqual(expected) - }) - - describe('who mentions me many times', () => { - const updatePostAction = async () => { - const updatedContent = ` - One more mention to - - @al-capone - - and again: - - @al-capone - - and again - - @al-capone - - ` - authenticatedUser = await postAuthor.toJson() - await mutate({ - mutation: updatePostMutation, - variables: { - id: 'p47', - title, - content: updatedContent, - categoryIds, - }, - }) - authenticatedUser = await user.toJson() - } - - it('creates exactly one more notification', async () => { - await createPostAction() - await updatePostAction() - const expectedContent = - '
One more mention to

@al-capone

and again:

@al-capone

and again

@al-capone

' - const expected = expect.objectContaining({ - data: { - currentUser: { - notifications: [ - { - read: false, - post: { - content: expectedContent, - }, - comment: null, - }, - { - read: false, - post: { - content: expectedContent, - }, - comment: null, - }, - ], - }, - }, - }) - await expect( - query({ - query: notificationQuery, - variables: { - read: false, - }, - }), - ).resolves.toEqual(expected) - }) - }) - - describe('but the author of the post blocked me', () => { - beforeEach(async () => { - await postAuthor.relateTo(user, 'blocked') - }) - - it('sends no notification', async () => { - await createPostAction() - const expected = expect.objectContaining({ - data: { - currentUser: { - notifications: [], - }, - }, - }) - const { query } = createTestClient(server) - await expect( - query({ - query: notificationQuery, - variables: { - 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 .', - }, - }) - 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) - }) - }) - }) - }) - }) -}) - -describe('Hashtags', () => { - const id = 'p135' - const title = 'Two Hashtags' - const content = - '

Hey Dude, #Democracy should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' - const postWithHastagsQuery = gql` - query($id: ID) { - Post(id: $id) { - tags { - id - } - } - } - ` - const postWithHastagsVariables = { - id, - } - - describe('authenticated', () => { - beforeEach(async () => { - authenticatedUser = await user.toJson() - }) - - describe('create a Post with Hashtags', () => { - beforeEach(async () => { - await mutate({ - mutation: createPostMutation, - variables: { - id, - title, - content, - categoryIds, - }, - }) - }) - - it('both Hashtags are created with the "id" set to their "name"', async () => { - const expected = [{ id: 'Democracy' }, { id: 'Liberty' }] - await expect( - query({ - query: postWithHastagsQuery, - variables: postWithHastagsVariables, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - Post: [ - { - tags: expect.arrayContaining(expected), - }, - ], - }, - }), - ) - }) - - describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => { - // The already existing Hashtag has no class at this point. - const content = - '

Hey Dude, #Elections should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' - - it('only one previous Hashtag and the new Hashtag exists', async () => { - await mutate({ - mutation: updatePostMutation, - variables: { - id, - title, - content, - categoryIds, - }, - }) - - const expected = [{ id: 'Elections' }, { id: 'Liberty' }] - await expect( - query({ - query: postWithHastagsQuery, - variables: postWithHastagsVariables, - }), - ).resolves.toEqual( - expect.objectContaining({ - data: { - Post: [ - { - tags: expect.arrayContaining(expected), - }, - ], - }, - }), - ) - }) - }) - }) - }) -}) diff --git a/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js b/backend/src/middleware/hashtags/extractHashtags.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js rename to backend/src/middleware/hashtags/extractHashtags.js diff --git a/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js b/backend/src/middleware/hashtags/extractHashtags.spec.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js rename to backend/src/middleware/hashtags/extractHashtags.spec.js diff --git a/backend/src/middleware/hashtags/hashtagsMiddleware.js b/backend/src/middleware/hashtags/hashtagsMiddleware.js new file mode 100644 index 000000000..c9156398d --- /dev/null +++ b/backend/src/middleware/hashtags/hashtagsMiddleware.js @@ -0,0 +1,49 @@ +import extractHashtags from '../hashtags/extractHashtags' + +const updateHashtagsOfPost = async (postId, hashtags, context) => { + if (!hashtags.length) return + + const session = context.driver.session() + // We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement + // functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted + // and no new Hashtags and relations will be created. + const cypherDeletePreviousRelations = ` + MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag) + DELETE previousRelations + RETURN p, t + ` + const cypherCreateNewTagsAndRelations = ` + MATCH (p: Post { id: $postId}) + UNWIND $hashtags AS tagName + MERGE (t: Tag { id: tagName, disabled: false, deleted: false }) + MERGE (p)-[:TAGGED]->(t) + RETURN p, t + ` + await session.run(cypherDeletePreviousRelations, { + postId, + }) + await session.run(cypherCreateNewTagsAndRelations, { + postId, + hashtags, + }) + session.close() +} + +const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { + const hashtags = extractHashtags(args.content) + + const post = await resolve(root, args, context, resolveInfo) + + if (post) { + await updateHashtagsOfPost(post.id, hashtags, context) + } + + return post +} + +export default { + Mutation: { + CreatePost: handleContentDataOfPost, + UpdatePost: handleContentDataOfPost, + }, +} diff --git a/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js b/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js new file mode 100644 index 000000000..3f101f778 --- /dev/null +++ b/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js @@ -0,0 +1,176 @@ +import { gql } from '../../jest/helpers' +import Factory from '../../seed/factories' +import { createTestClient } from 'apollo-server-testing' +import { neode, getDriver } from '../../bootstrap/neo4j' +import createServer from '../../server' + +let server +let query +let mutate +let hashtagingUser +let authenticatedUser +const factory = Factory() +const driver = getDriver() +const instance = neode() +const categoryIds = ['cat9'] +const createPostMutation = gql` + mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + CreatePost(id: $id, title: $title, content: $postContent, categoryIds: $categoryIds) { + id + title + content + } + } +` +const updatePostMutation = gql` + mutation($id: ID!, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + UpdatePost(id: $id, content: $postContent, title: $title, categoryIds: $categoryIds) { + title + content + } + } +` + +beforeAll(() => { + const createServerResult = createServer({ + context: () => { + return { + user: authenticatedUser, + neode: instance, + driver, + } + }, + }) + server = createServerResult.server + const createTestClientResult = createTestClient(server) + query = createTestClientResult.query + mutate = createTestClientResult.mutate +}) + +beforeEach(async () => { + hashtagingUser = await instance.create('User', { + id: 'you', + name: 'Al Capone', + slug: 'al-capone', + email: 'test@example.org', + password: '1234', + }) + await instance.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('hashtags', () => { + const id = 'p135' + const title = 'Two Hashtags' + const postContent = + '

Hey Dude, #Democracy should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' + const postWithHastagsQuery = gql` + query($id: ID) { + Post(id: $id) { + tags { + id + } + } + } + ` + const postWithHastagsVariables = { + id, + } + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await hashtagingUser.toJson() + }) + + describe('create a Post with Hashtags', () => { + beforeEach(async () => { + await mutate({ + mutation: createPostMutation, + variables: { + id, + title, + postContent, + categoryIds, + }, + }) + }) + + it('both hashtags are created with the "id" set to their "name"', async () => { + const expected = [ + { + id: 'Democracy', + }, + { + id: 'Liberty', + }, + ] + await expect( + query({ + query: postWithHastagsQuery, + variables: postWithHastagsVariables, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [ + { + tags: expect.arrayContaining(expected), + }, + ], + }, + }), + ) + }) + + describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => { + // The already existing Hashtag has no class at this point. + const postContent = + '

Hey Dude, #Elections should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' + + it('only one previous Hashtag and the new Hashtag exists', async () => { + await mutate({ + mutation: updatePostMutation, + variables: { + id, + title, + postContent, + categoryIds, + }, + }) + + const expected = [ + { + id: 'Elections', + }, + { + id: 'Liberty', + }, + ] + await expect( + query({ + query: postWithHastagsQuery, + variables: postWithHastagsVariables, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + Post: [ + { + tags: expect.arrayContaining(expected), + }, + ], + }, + }), + ) + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 57bdabfc9..7774ccc15 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -12,7 +12,8 @@ import user from './userMiddleware' import includedFields from './includedFieldsMiddleware' import orderBy from './orderByMiddleware' import validation from './validation/validationMiddleware' -import handleContentData from './handleHtmlContent/handleContentData' +import notifications from './notifications/notificationsMiddleware' +import hashtags from './hashtags/hashtagsMiddleware' import email from './email/emailMiddleware' import sentry from './sentryMiddleware' @@ -25,13 +26,16 @@ export default schema => { validation, sluggify, excerpt, - handleContentData, + notifications, + hashtags, xss, softDelete, user, includedFields, orderBy, - email: email({ isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT }), + email: email({ + isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT, + }), } let order = [ @@ -43,7 +47,8 @@ export default schema => { 'sluggify', 'excerpt', 'email', - 'handleContentData', + 'notifications', + 'hashtags', 'xss', 'softDelete', 'user', diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js b/backend/src/middleware/notifications/mentions/extractMentionedUsers.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js rename to backend/src/middleware/notifications/mentions/extractMentionedUsers.js diff --git a/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js b/backend/src/middleware/notifications/mentions/extractMentionedUsers.spec.js similarity index 100% rename from backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js rename to backend/src/middleware/notifications/mentions/extractMentionedUsers.spec.js diff --git a/backend/src/middleware/notifications/notificationsMiddleware.js b/backend/src/middleware/notifications/notificationsMiddleware.js new file mode 100644 index 000000000..c9dfe406c --- /dev/null +++ b/backend/src/middleware/notifications/notificationsMiddleware.js @@ -0,0 +1,122 @@ +import extractMentionedUsers from './mentions/extractMentionedUsers' + +const notifyUsers = async (label, id, idsOfUsers, reason, context) => { + if (!idsOfUsers.length) return + + // Checked here, because it does not go through GraphQL checks at all in this file. + const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post'] + if (!reasonsAllowed.includes(reason)) { + throw new Error('Notification reason is not allowed!') + } + if ( + (label === 'Post' && reason !== 'mentioned_in_post') || + (label === 'Comment' && !['mentioned_in_comment', 'comment_on_post'].includes(reason)) + ) { + throw new Error('Notification does not fit the reason!') + } + + const session = context.driver.session() + const createdAt = new Date().toISOString() + let cypher + switch (reason) { + case 'mentioned_in_post': { + cypher = ` + MATCH (post: Post { id: $id })<-[:WROTE]-(author: User) + MATCH (user: User) + WHERE user.id in $idsOfUsers + AND NOT (user)<-[:BLOCKED]-(author) + CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt }) + MERGE (post)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user) + ` + break + } + case 'mentioned_in_comment': { + 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) + ` + break + } + case 'comment_on_post': { + 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 (author)<-[:BLOCKED]-(user) + CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt }) + MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user) + ` + break + } + } + await session.run(cypher, { + label, + id, + idsOfUsers, + reason, + createdAt, + }) + session.close() +} + +const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { + const idsOfUsers = extractMentionedUsers(args.content) + + const post = await resolve(root, args, context, resolveInfo) + + if (post) { + await notifyUsers('Post', post.id, idsOfUsers, 'mentioned_in_post', context) + } + + return post +} + +const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => { + const idsOfUsers = extractMentionedUsers(args.content) + const comment = await resolve(root, args, context, resolveInfo) + + if (comment) { + await notifyUsers('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context) + } + + return comment +} + +const handleCreateComment = async (resolve, root, args, context, resolveInfo) => { + const comment = await handleContentDataOfComment(resolve, root, args, context, resolveInfo) + + if (comment) { + const session = context.driver.session() + const cypherFindUser = ` + MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId }) + RETURN user { .id } + ` + const result = await session.run(cypherFindUser, { + commentId: comment.id, + }) + session.close() + const [postAuthor] = await result.records.map(record => { + return record.get('user') + }) + if (context.user.id !== postAuthor.id) { + await notifyUsers('Comment', comment.id, [postAuthor.id], 'comment_on_post', context) + } + } + + return comment +} + +export default { + Mutation: { + CreatePost: handleContentDataOfPost, + UpdatePost: handleContentDataOfPost, + CreateComment: handleCreateComment, + UpdateComment: handleContentDataOfComment, + }, +} diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.js b/backend/src/middleware/notifications/notificationsMiddleware.spec.js new file mode 100644 index 000000000..624cedddc --- /dev/null +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.js @@ -0,0 +1,463 @@ +import { gql } from '../../jest/helpers' +import Factory from '../../seed/factories' +import { createTestClient } from 'apollo-server-testing' +import { neode, getDriver } from '../../bootstrap/neo4j' +import createServer from '../../server' + +let server +let query +let mutate +let notifiedUser +let authenticatedUser +const factory = Factory() +const driver = getDriver() +const instance = neode() +const categoryIds = ['cat9'] +const createPostMutation = gql` + mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + CreatePost(id: $id, title: $title, content: $postContent, categoryIds: $categoryIds) { + id + title + content + } + } +` +const updatePostMutation = gql` + mutation($id: ID!, $title: String!, $postContent: String!, $categoryIds: [ID]!) { + UpdatePost(id: $id, content: $postContent, title: $title, categoryIds: $categoryIds) { + title + content + } + } +` +const createCommentMutation = gql` + mutation($id: ID, $postId: ID!, $commentContent: String!) { + CreateComment(id: $id, postId: $postId, content: $commentContent) { + id + content + } + } +` + +beforeAll(() => { + const createServerResult = createServer({ + context: () => { + return { + user: authenticatedUser, + neode: instance, + driver, + } + }, + }) + server = createServerResult.server + const createTestClientResult = createTestClient(server) + query = createTestClientResult.query + mutate = createTestClientResult.mutate +}) + +beforeEach(async () => { + notifiedUser = await instance.create('User', { + id: 'you', + name: 'Al Capone', + slug: 'al-capone', + email: 'test@example.org', + password: '1234', + }) + await instance.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('notifications', () => { + const notificationQuery = gql` + query($read: Boolean) { + currentUser { + notifications(read: $read, orderBy: createdAt_desc) { + read + reason + post { + content + } + comment { + content + } + } + } + } + ` + + describe('authenticated', () => { + beforeEach(async () => { + authenticatedUser = await notifiedUser.toJson() + }) + + describe('given another user', () => { + let title + let postContent + let postAuthor + const createPostAction = async () => { + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: createPostMutation, + variables: { + id: 'p47', + title, + postContent, + categoryIds, + }, + }) + authenticatedUser = await notifiedUser.toJson() + } + + let commentContent + let commentAuthor + const createCommentOnPostAction = async () => { + await createPostAction() + authenticatedUser = await commentAuthor.toJson() + await mutate({ + mutation: createCommentMutation, + variables: { + id: 'c47', + postId: 'p47', + commentContent, + }, + }) + authenticatedUser = await notifiedUser.toJson() + } + + describe('comments on my post', () => { + beforeEach(async () => { + title = 'My post' + postContent = 'My post content.' + postAuthor = notifiedUser + }) + + describe('commenter is not me', () => { + beforeEach(async () => { + commentContent = 'Commenters comment.' + commentAuthor = await instance.create('User', { + id: 'commentAuthor', + name: 'Mrs Comment', + slug: 'mrs-comment', + email: 'commentauthor@example.org', + password: '1234', + }) + }) + + it('sends me a notification', async () => { + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [ + { + read: false, + reason: 'comment_on_post', + post: null, + comment: { + content: commentContent, + }, + }, + ], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + + it('sends me no notification if I have blocked the comment author', async () => { + await notifiedUser.relateTo(commentAuthor, 'blocked') + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + + describe('commenter is me', () => { + beforeEach(async () => { + commentContent = 'My comment.' + commentAuthor = notifiedUser + }) + + it('sends me no notification', async () => { + await notifiedUser.relateTo(commentAuthor, 'blocked') + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + }) + + beforeEach(async () => { + postAuthor = await instance.create('User', { + id: 'postAuthor', + name: 'Mrs Post', + slug: 'mrs-post', + email: 'post-author@example.org', + password: '1234', + }) + }) + + describe('mentions me in a post', () => { + beforeEach(async () => { + title = 'Mentioning Al Capone' + postContent = + 'Hey @al-capone how do you do?' + }) + + it('sends me a notification', async () => { + await createPostAction() + const expectedContent = + 'Hey @al-capone how do you do?' + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [ + { + read: false, + reason: 'mentioned_in_post', + post: { + content: expectedContent, + }, + comment: null, + }, + ], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + + describe('many times', () => { + const updatePostAction = async () => { + const updatedContent = ` + One more mention to + + @al-capone + + and again: + + @al-capone + + and again + + @al-capone + + ` + authenticatedUser = await postAuthor.toJson() + await mutate({ + mutation: updatePostMutation, + variables: { + id: 'p47', + title, + postContent: updatedContent, + categoryIds, + }, + }) + authenticatedUser = await notifiedUser.toJson() + } + + it('creates exactly one more notification', async () => { + await createPostAction() + await updatePostAction() + const expectedContent = + '
One more mention to

@al-capone

and again:

@al-capone

and again

@al-capone

' + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [ + { + read: false, + reason: 'mentioned_in_post', + post: { + content: expectedContent, + }, + comment: null, + }, + { + read: false, + reason: 'mentioned_in_post', + post: { + content: expectedContent, + }, + comment: null, + }, + ], + }, + }, + }) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + + describe('but the author of the post blocked me', () => { + beforeEach(async () => { + await postAuthor.relateTo(notifiedUser, 'blocked') + }) + + it('sends no notification', async () => { + await createPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + }) + + describe('mentions me in a comment', () => { + beforeEach(async () => { + title = 'Post where I get mentioned in a comment' + postContent = 'Content of post where I get mentioned in a comment.' + }) + + describe('I am not blocked at all', () => { + beforeEach(async () => { + commentContent = + 'One mention about me with @al-capone.' + commentAuthor = await instance.create('User', { + id: 'commentAuthor', + name: 'Mrs Comment', + slug: 'mrs-comment', + email: 'comment-author@example.org', + password: '1234', + }) + }) + + it('sends a notification', async () => { + await createCommentOnPostAction() + const expected = expect.objectContaining({ + data: { + currentUser: { + notifications: [ + { + read: false, + reason: 'mentioned_in_comment', + post: null, + comment: { + content: commentContent, + }, + }, + ], + }, + }, + }) + const { query } = createTestClient(server) + await expect( + query({ + query: notificationQuery, + variables: { + read: false, + }, + }), + ).resolves.toEqual(expected) + }) + }) + + describe('but the author of the post blocked me', () => { + beforeEach(async () => { + await postAuthor.relateTo(notifiedUser, 'blocked') + commentContent = + 'One mention about me with @al-capone.' + commentAuthor = await instance.create('User', { + id: 'commentAuthor', + name: 'Mrs Comment', + slug: 'mrs-comment', + email: 'comment-author@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) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/models/Notification.js b/backend/src/models/Notification.js index b8690b8c1..b54a99574 100644 --- a/backend/src/models/Notification.js +++ b/backend/src/models/Notification.js @@ -1,9 +1,26 @@ import uuid from 'uuid/v4' module.exports = { - id: { type: 'uuid', primary: true, default: uuid }, - createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, - read: { type: 'boolean', default: false }, + id: { + type: 'uuid', + primary: true, + default: uuid, + }, + read: { + type: 'boolean', + default: false, + }, + reason: { + type: 'string', + valid: ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post'], + invalid: [null], + default: 'mentioned_in_post', + }, + createdAt: { + type: 'string', + isoDate: true, + default: () => new Date().toISOString(), + }, user: { type: 'relationship', relationship: 'NOTIFIED', diff --git a/backend/src/schema/resolvers/notifications.spec.js b/backend/src/schema/resolvers/notifications.spec.js index 6b173514f..3ca7727e4 100644 --- a/backend/src/schema/resolvers/notifications.spec.js +++ b/backend/src/schema/resolvers/notifications.spec.js @@ -69,23 +69,29 @@ describe('currentUser notifications', () => { factory.create('User', neighborParams), factory.create('Notification', { id: 'post-mention-not-for-you', + reason: 'mentioned_in_post', }), factory.create('Notification', { id: 'post-mention-already-seen', read: true, + reason: 'mentioned_in_post', }), factory.create('Notification', { id: 'post-mention-unseen', + reason: 'mentioned_in_post', }), factory.create('Notification', { id: 'comment-mention-not-for-you', + reason: 'mentioned_in_comment', }), factory.create('Notification', { id: 'comment-mention-already-seen', read: true, + reason: 'mentioned_in_comment', }), factory.create('Notification', { id: 'comment-mention-unseen', + reason: 'mentioned_in_comment', }), ]) await factory.authenticateAs(neighborParams) @@ -287,9 +293,11 @@ describe('UpdateNotification', () => { factory.create('User', mentionedParams), factory.create('Notification', { id: 'post-mention-to-be-updated', + reason: 'mentioned_in_post', }), factory.create('Notification', { id: 'comment-mention-to-be-updated', + reason: 'mentioned_in_comment', }), ]) await factory.authenticateAs(userParams) diff --git a/backend/src/schema/types/enum/ReasonNotification.gql b/backend/src/schema/types/enum/ReasonNotification.gql new file mode 100644 index 000000000..a66c446be --- /dev/null +++ b/backend/src/schema/types/enum/ReasonNotification.gql @@ -0,0 +1,5 @@ +enum ReasonNotification { + mentioned_in_post + mentioned_in_comment + comment_on_post +} \ No newline at end of file diff --git a/backend/src/schema/types/type/Notification.gql b/backend/src/schema/types/type/Notification.gql index 0f94c2301..a3543445f 100644 --- a/backend/src/schema/types/type/Notification.gql +++ b/backend/src/schema/types/type/Notification.gql @@ -1,8 +1,9 @@ type Notification { id: ID! read: Boolean + reason: ReasonNotification + createdAt: String user: User @relation(name: "NOTIFIED", direction: "OUT") post: Post @relation(name: "NOTIFIED", direction: "IN") comment: Comment @relation(name: "NOTIFIED", direction: "IN") - createdAt: String } diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index df3886a6c..56518bd06 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -62,15 +62,26 @@ export default function Factory(options = {}) { lastResponse: null, neodeInstance, async authenticateAs({ email, password }) { - const headers = await authenticatedHeaders({ email, password }, seedServerHost) + const headers = await authenticatedHeaders( + { + email, + password, + }, + seedServerHost, + ) this.lastResponse = headers - this.graphQLClient = new GraphQLClient(seedServerHost, { headers }) + this.graphQLClient = new GraphQLClient(seedServerHost, { + headers, + }) return this }, async create(node, args = {}) { const { factory, mutation, variables } = this.factories[node](args) if (factory) { - this.lastResponse = await factory({ args, neodeInstance }) + this.lastResponse = await factory({ + args, + neodeInstance, + }) return this.lastResponse } else { this.lastResponse = await this.graphQLClient.request(mutation, variables) @@ -121,11 +132,15 @@ export default function Factory(options = {}) { }, async invite({ email }) { const mutation = ` mutation($email: String!) { invite( email: $email) } ` - this.lastResponse = await this.graphQLClient.request(mutation, { email }) + this.lastResponse = await this.graphQLClient.request(mutation, { + email, + }) return this }, async cleanDatabase() { - this.lastResponse = await cleanDatabase({ driver: this.neo4jDriver }) + this.lastResponse = await cleanDatabase({ + driver: this.neo4jDriver, + }) return this }, async emote({ to, data }) { diff --git a/backend/yarn.lock b/backend/yarn.lock index 2f6f02d8e..9d1abf176 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -4193,10 +4193,10 @@ graphql-upload@^8.0.2: http-errors "^1.7.2" object-path "^0.11.4" -graphql@^14.2.1, graphql@^14.5.0: - version "14.5.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.0.tgz#4801e6460942c9c591944617f6dd224a9e531520" - integrity sha512-wnGcTD181L2xPnIwHHjx/moV4ulxA2Kms9zcUY+B/SIrK+2N+iOC6WNgnR2zVTmg1Z8P+CZq5KXibTnatg3WUw== +graphql@^14.2.1, graphql@^14.5.3: + version "14.5.3" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.3.tgz#e025851cc413e153220f4edbbb25d49f55104fa0" + integrity sha512-W8A8nt9BsMg0ZK2qA3DJIVU6muWhxZRYLTmc+5XGwzWzVdUdPVlAAg5hTBjiTISEnzsKL/onasu6vl3kgGTbYg== dependencies: iterall "^1.2.2" diff --git a/deployment/human-connection/deployment-neo4j.yaml b/deployment/human-connection/deployment-neo4j.yaml index 2fcba9061..297f4b551 100644 --- a/deployment/human-connection/deployment-neo4j.yaml +++ b/deployment/human-connection/deployment-neo4j.yaml @@ -25,13 +25,20 @@ - name: nitro-neo4j image: humanconnection/neo4j:latest imagePullPolicy: Always + resources: + requests: + memory: "1G" + limits: + memory: "2G" env: - name: NEO4J_apoc_import_file_enabled value: "true" - name: NEO4J_dbms_memory_pagecache_size - value: 1G + value: "490M" - name: NEO4J_dbms_memory_heap_max__size - value: 1G + value: "500M" + - name: NEO4J_dbms_memory_heap_initial__size + value: "500M" - name: NEO4J_dbms_security_procedures_unrestricted value: "algo.*,apoc.*" envFrom: diff --git a/package.json b/package.json index 30f122f08..7569217fb 100644 --- a/package.json +++ b/package.json @@ -34,4 +34,4 @@ "npm-run-all": "^4.1.5", "slug": "^1.1.0" } -} \ No newline at end of file +} diff --git a/webapp/components/Comment.spec.js b/webapp/components/Comment.spec.js index 4fdc48bbd..b9be448e4 100644 --- a/webapp/components/Comment.spec.js +++ b/webapp/components/Comment.spec.js @@ -8,7 +8,7 @@ const localVue = createLocalVue() localVue.use(Vuex) localVue.use(Styleguide) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' describe('Comment.vue', () => { let propsData diff --git a/webapp/components/Comment.vue b/webapp/components/Comment.vue index 6d3b05eff..16d717430 100644 --- a/webapp/components/Comment.vue +++ b/webapp/components/Comment.vue @@ -15,7 +15,7 @@ - + - +
diff --git a/webapp/components/CommentList/CommentList.spec.js b/webapp/components/CommentList/CommentList.spec.js index e1090475a..5551227a1 100644 --- a/webapp/components/CommentList/CommentList.spec.js +++ b/webapp/components/CommentList/CommentList.spec.js @@ -14,7 +14,7 @@ localVue.filter('truncate', string => string) config.stubs['v-popover'] = '' config.stubs['nuxt-link'] = '' -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' describe('CommentList.vue', () => { let mocks diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index e1f293c77..90ed226ee 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -16,7 +16,7 @@ localVue.use(Vuex) localVue.use(Styleguide) localVue.use(Filters) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' config.stubs['nuxt-link'] = '' config.stubs['v-popover'] = '' diff --git a/webapp/components/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue index e3daf753d..b54cd4f37 100644 --- a/webapp/components/ContributionForm/ContributionForm.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -14,7 +14,7 @@ {{ form.title.length }}/{{ formSchema.title.max }} - + {{ form.contentLength }}/{{ contentMax }} - + - + - + diff --git a/webapp/components/EditCommentForm/EditCommentForm.vue b/webapp/components/EditCommentForm/EditCommentForm.vue index e647131b8..0a5cd7d98 100644 --- a/webapp/components/EditCommentForm/EditCommentForm.vue +++ b/webapp/components/EditCommentForm/EditCommentForm.vue @@ -2,7 +2,7 @@ diff --git a/webapp/components/User/index.vue b/webapp/components/User/index.vue index 0fefe4eb3..684220f38 100644 --- a/webapp/components/User/index.vue +++ b/webapp/components/User/index.vue @@ -25,9 +25,9 @@
- + - +
diff --git a/webapp/components/notifications/Notification/Notification.spec.js b/webapp/components/notifications/Notification/Notification.spec.js index 8fbc524fb..279500f7f 100644 --- a/webapp/components/notifications/Notification/Notification.spec.js +++ b/webapp/components/notifications/Notification/Notification.spec.js @@ -8,16 +8,17 @@ const localVue = createLocalVue() localVue.use(Styleguide) localVue.use(Filters) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' describe('Notification', () => { let stubs let mocks let propsData + let wrapper beforeEach(() => { propsData = {} mocks = { - $t: jest.fn(), + $t: key => key, } stubs = { NuxtLink: RouterLinkStub, @@ -33,37 +34,159 @@ describe('Notification', () => { }) } - describe('given a notification', () => { + describe('given a notification about a comment on a post', () => { beforeEach(() => { propsData.notification = { - post: { - title: "It's a title", - id: 'post-1', - slug: 'its-a-title', - contentExcerpt: '@jenny-rostock is the best', + reason: 'comment_on_post', + post: null, + comment: { + id: 'comment-1', + contentExcerpt: + '@dagobert-duck is the best on this comment.', + post: { + title: "It's a post title", + id: 'post-1', + slug: 'its-a-title', + contentExcerpt: 'Post content.', + }, }, } }) + it('renders reason', () => { + wrapper = Wrapper() + expect(wrapper.find('.reason-text-for-test').text()).toEqual( + 'notifications.menu.comment_on_post', + ) + }) it('renders title', () => { - expect(Wrapper().text()).toContain("It's a title") + wrapper = Wrapper() + expect(wrapper.text()).toContain("It's a post title") + }) + it('renders the "Comment:"', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('Comment:') }) - it('renders the contentExcerpt', () => { - expect(Wrapper().text()).toContain('@jenny-rostock is the best') + wrapper = Wrapper() + expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.') }) - it('has no class "read"', () => { - expect(Wrapper().classes()).not.toContain('read') + wrapper = Wrapper() + expect(wrapper.classes()).not.toContain('read') }) describe('that is read', () => { beforeEach(() => { propsData.notification.read = true + wrapper = Wrapper() }) it('has class "read"', () => { - expect(Wrapper().classes()).toContain('read') + expect(wrapper.classes()).toContain('read') + }) + }) + }) + + describe('given a notification about a mention in a post', () => { + beforeEach(() => { + propsData.notification = { + reason: 'mentioned_in_post', + post: { + title: "It's a post title", + id: 'post-1', + slug: 'its-a-title', + contentExcerpt: + '@jenny-rostock is the best on this post.', + }, + comment: null, + } + }) + + it('renders reason', () => { + wrapper = Wrapper() + expect(wrapper.find('.reason-text-for-test').text()).toEqual( + 'notifications.menu.mentioned_in_post', + ) + }) + it('renders title', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain("It's a post title") + }) + it('renders the contentExcerpt', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('@jenny-rostock is the best on this post.') + }) + it('has no class "read"', () => { + wrapper = Wrapper() + expect(wrapper.classes()).not.toContain('read') + }) + + describe('that is read', () => { + beforeEach(() => { + propsData.notification.read = true + wrapper = Wrapper() + }) + + it('has class "read"', () => { + expect(wrapper.classes()).toContain('read') + }) + }) + }) + + describe('given a notification about a mention in a comment', () => { + beforeEach(() => { + propsData.notification = { + reason: 'mentioned_in_comment', + post: null, + comment: { + id: 'comment-1', + contentExcerpt: + '@dagobert-duck is the best on this comment.', + post: { + title: "It's a post title", + id: 'post-1', + slug: 'its-a-title', + contentExcerpt: 'Post content.', + }, + }, + } + }) + + it('renders reason', () => { + wrapper = Wrapper() + expect(wrapper.find('.reason-text-for-test').text()).toEqual( + 'notifications.menu.mentioned_in_comment', + ) + }) + it('renders title', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain("It's a post title") + }) + + it('renders the "Comment:"', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('Comment:') + }) + + it('renders the contentExcerpt', () => { + wrapper = Wrapper() + expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.') + }) + + it('has no class "read"', () => { + wrapper = Wrapper() + expect(wrapper.classes()).not.toContain('read') + }) + + describe('that is read', () => { + beforeEach(() => { + propsData.notification.read = true + wrapper = Wrapper() + }) + + it('has class "read"', () => { + expect(wrapper.classes()).toContain('read') }) }) }) diff --git a/webapp/components/notifications/Notification/Notification.vue b/webapp/components/notifications/Notification/Notification.vue index 6aa4a5eeb..193b5f67b 100644 --- a/webapp/components/notifications/Notification/Notification.vue +++ b/webapp/components/notifications/Notification/Notification.vue @@ -1,6 +1,6 @@ diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 99689ec44..bb70ed36e 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -14,7 +14,7 @@ "all": "Alle" }, "general": { - "header": "Filtern nach..." + "header": "Filtern nach …" }, "followers": { "label": "Benutzern, denen ich folge" @@ -96,7 +96,7 @@ } }, "editor": { - "placeholder": "Schreib etwas Inspirierendes...", + "placeholder": "Schreib etwas Inspirierendes …", "mention": { "noUsersFound": "Keine Benutzer gefunden" }, @@ -132,7 +132,9 @@ }, "notifications": { "menu": { - "mentioned": "hat dich in einem {resource} erwähnt" + "mentioned_in_post": "Hat dich in einem Beitrag erwähnt …", + "mentioned_in_comment": "Hat dich in einem Kommentar erwähnt …", + "comment_on_post": "Hat deinen Beitrag kommentiert …" } }, "search": { @@ -304,7 +306,7 @@ }, "comment": { "content": { - "unavailable-placeholder": "...dieser Kommentar ist nicht mehr verfügbar" + "unavailable-placeholder": "… dieser Kommentar ist nicht mehr verfügbar" }, "menu": { "edit": "Kommentar bearbeiten", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 9c9d1cd73..94451fa88 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -14,7 +14,7 @@ "all": "All" }, "general": { - "header": "Filter by..." + "header": "Filter by …" }, "followers": { "label": "Users I follow" @@ -96,7 +96,7 @@ } }, "editor": { - "placeholder": "Leave your inspirational thoughts...", + "placeholder": "Leave your inspirational thoughts …", "mention": { "noUsersFound": "No users found" }, @@ -132,7 +132,9 @@ }, "notifications": { "menu": { - "mentioned": "mentioned you in a {resource}" + "mentioned_in_post": "Mentioned you in a post …", + "mentioned_in_comment": "Mentioned you in a comment …", + "comment_on_post": "Commented on your post …" } }, "search": { @@ -304,7 +306,7 @@ }, "comment": { "content": { - "unavailable-placeholder": "...this comment is not available anymore" + "unavailable-placeholder": "… this comment is not available anymore" }, "menu": { "edit": "Edit Comment", diff --git a/webapp/package.json b/webapp/package.json index 9ed991e62..6357e06e5 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -61,7 +61,7 @@ "apollo-client": "~2.6.4", "cookie-universal-nuxt": "~2.0.17", "cross-env": "~5.2.0", - "date-fns": "2.0.0", + "date-fns": "2.0.1", "express": "~4.17.1", "graphql": "~14.5.3", "isemail": "^3.2.0", diff --git a/webapp/pages/admin/index.vue b/webapp/pages/admin/index.vue index ca8ce4df7..f764238e3 100644 --- a/webapp/pages/admin/index.vue +++ b/webapp/pages/admin/index.vue @@ -1,23 +1,23 @@ diff --git a/webapp/pages/index.spec.js b/webapp/pages/index.spec.js index 6dacd6069..3a97e3709 100644 --- a/webapp/pages/index.spec.js +++ b/webapp/pages/index.spec.js @@ -15,7 +15,7 @@ localVue.use(Filters) localVue.use(VTooltip) localVue.use(InfiniteScroll) -config.stubs['no-ssr'] = '' +config.stubs['client-only'] = '' config.stubs['router-link'] = '' config.stubs['nuxt-link'] = '' diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index b9db7aa37..afc566940 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -36,7 +36,7 @@ - + - +