diff --git a/backend/src/graphql/messages.ts b/backend/src/graphql/messages.ts index 4d2220f18..ca5ffb952 100644 --- a/backend/src/graphql/messages.ts +++ b/backend/src/graphql/messages.ts @@ -6,6 +6,9 @@ export const createMessageMutation = () => { CreateMessage(roomId: $roomId, content: $content) { id content + saved + distributed + seen } } ` @@ -13,16 +16,28 @@ export const createMessageMutation = () => { export const messageQuery = () => { return gql` - query ($roomId: ID!) { - Message(roomId: $roomId) { + query ($roomId: ID!, $first: Int, $offset: Int) { + Message(roomId: $roomId, first: $first, offset: $offset, orderBy: createdAt_desc) { _id id + indexId content senderId username avatar date + saved + distributed + seen } } ` } + +export const markMessagesAsSeen = () => { + return gql` + mutation ($messageIds: [String!]) { + MarkMessagesAsSeen(messageIds: $messageIds) + } + ` +} diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index 81ba93e3c..c07098a3c 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -463,6 +463,7 @@ export default shield( saveCategorySettings: isAuthenticated, CreateRoom: isAuthenticated, CreateMessage: isAuthenticated, + MarkMessagesAsSeen: isAuthenticated, }, User: { email: or(isMyOwn, isAdmin), diff --git a/backend/src/schema/resolvers/messages.spec.ts b/backend/src/schema/resolvers/messages.spec.ts index a43bd3226..d0f1d7871 100644 --- a/backend/src/schema/resolvers/messages.spec.ts +++ b/backend/src/schema/resolvers/messages.spec.ts @@ -2,7 +2,7 @@ import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../../db/factories' import { getNeode, getDriver } from '../../db/neo4j' import { createRoomMutation } from '../../graphql/rooms' -import { createMessageMutation, messageQuery } from '../../graphql/messages' +import { createMessageMutation, messageQuery, markMessagesAsSeen } from '../../graphql/messages' import createServer from '../../server' const driver = getDriver() @@ -122,6 +122,9 @@ describe('Message', () => { CreateMessage: { id: expect.any(String), content: 'Some nice message to other chatting user', + saved: true, + distributed: false, + seen: false, }, }, }) @@ -212,11 +215,15 @@ describe('Message', () => { { id: expect.any(String), _id: result.data.Message[0].id, + indexId: 0, content: 'Some nice message to other chatting user', senderId: 'chatting-user', username: 'Chatting User', avatar: expect.any(String), date: expect.any(String), + saved: true, + distributed: true, + seen: false, }, ], }, @@ -253,17 +260,65 @@ describe('Message', () => { ).resolves.toMatchObject({ errors: undefined, data: { - Message: expect.arrayContaining([ + Message: [ expect.objectContaining({ id: expect.any(String), + indexId: 0, content: 'Some nice message to other chatting user', senderId: 'chatting-user', username: 'Chatting User', avatar: expect.any(String), date: expect.any(String), + saved: true, + distributed: true, + seen: false, }), expect.objectContaining({ id: expect.any(String), + indexId: 1, + content: 'A nice response message to chatting user', + senderId: 'other-chatting-user', + username: 'Other Chatting User', + avatar: expect.any(String), + date: expect.any(String), + saved: true, + distributed: true, + seen: false, + }), + expect.objectContaining({ + id: expect.any(String), + indexId: 2, + content: 'And another nice message to other chatting user', + senderId: 'chatting-user', + username: 'Chatting User', + avatar: expect.any(String), + date: expect.any(String), + saved: true, + distributed: false, + seen: false, + }), + ], + }, + }) + }) + + it('returns the messages paginated', async () => { + await expect( + query({ + query: messageQuery(), + variables: { + roomId, + first: 2, + offset: 0, + }, + }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + Message: [ + expect.objectContaining({ + id: expect.any(String), + indexId: 1, content: 'A nice response message to chatting user', senderId: 'other-chatting-user', username: 'Other Chatting User', @@ -272,13 +327,40 @@ describe('Message', () => { }), expect.objectContaining({ id: expect.any(String), + indexId: 2, content: 'And another nice message to other chatting user', senderId: 'chatting-user', username: 'Chatting User', avatar: expect.any(String), date: expect.any(String), }), - ]), + ], + }, + }) + + await expect( + query({ + query: messageQuery(), + variables: { + roomId, + first: 2, + offset: 2, + }, + }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + Message: [ + expect.objectContaining({ + id: expect.any(String), + indexId: 0, + content: 'Some nice message to other chatting user', + senderId: 'chatting-user', + username: 'Chatting User', + avatar: expect.any(String), + date: expect.any(String), + }), + ], }, }) }) @@ -308,4 +390,74 @@ describe('Message', () => { }) }) }) + + describe('marks massges as seen', () => { + describe('unauthenticated', () => { + beforeAll(() => { + authenticatedUser = null + }) + + it('throws authorization error', async () => { + await expect( + mutate({ + mutation: markMessagesAsSeen(), + variables: { + messageIds: ['some-id'], + }, + }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + }) + }) + }) + + describe('authenticated', () => { + const messageIds: string[] = [] + beforeAll(async () => { + authenticatedUser = await otherChattingUser.toJson() + const msgs = await query({ + query: messageQuery(), + variables: { + roomId, + }, + }) + msgs.data.Message.forEach((m) => messageIds.push(m.id)) + }) + + it('returns true', async () => { + await expect( + mutate({ + mutation: markMessagesAsSeen(), + variables: { + messageIds, + }, + }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + MarkMessagesAsSeen: true, + }, + }) + }) + + it('has seen prop set to true', async () => { + await expect( + query({ + query: messageQuery(), + variables: { + roomId, + }, + }), + ).resolves.toMatchObject({ + data: { + Message: [ + expect.objectContaining({ seen: true }), + expect.objectContaining({ seen: false }), + expect.objectContaining({ seen: true }), + ], + }, + }) + }) + }) + }) }) diff --git a/backend/src/schema/resolvers/messages.ts b/backend/src/schema/resolvers/messages.ts index b93cffe06..a9937aac4 100644 --- a/backend/src/schema/resolvers/messages.ts +++ b/backend/src/schema/resolvers/messages.ts @@ -13,13 +13,42 @@ export default { id: context.user.id, }, } + const resolved = await neo4jgraphql(object, params, context, resolveInfo) + if (resolved) { + const undistributedMessagesIds = resolved + .filter((msg) => !msg.distributed && msg.senderId !== context.user.id) + .map((msg) => msg.id) + if (undistributedMessagesIds.length > 0) { + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const setDistributedCypher = ` + MATCH (m:Message) WHERE m.id IN $undistributedMessagesIds + SET m.distributed = true + RETURN m { .* } + ` + const setDistributedTxResponse = await transaction.run(setDistributedCypher, { + undistributedMessagesIds, + }) + const messages = await setDistributedTxResponse.records.map((record) => record.get('m')) + return messages + }) + try { + await writeTxResultPromise + } finally { + session.close() + } + // send subscription to author to updated the messages + } resolved.forEach((message) => { message._id = message.id + if (message.senderId !== context.user.id) { + message.distributed = true + } }) } - return resolved + return resolved.reverse() }, }, Mutation: { @@ -32,10 +61,16 @@ export default { const writeTxResultPromise = session.writeTransaction(async (transaction) => { const createMessageCypher = ` MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId }) + OPTIONAL MATCH (m:Message)-[:INSIDE]->(room) + WITH MAX(m.indexId) as maxIndex, room, currentUser CREATE (currentUser)-[:CREATED]->(message:Message { createdAt: toString(datetime()), id: apoc.create.uuid(), - content: $content + indexId: CASE WHEN maxIndex IS NOT NULL THEN maxIndex + 1 ELSE 0 END, + content: $content, + saved: true, + distributed: false, + seen: false })-[:INSIDE]->(room) RETURN message { .* } ` @@ -58,6 +93,32 @@ export default { session.close() } }, + MarkMessagesAsSeen: async (_parent, params, context, _resolveInfo) => { + const { messageIds } = params + const currentUserId = context.user.id + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const setSeenCypher = ` + MATCH (m:Message)<-[:CREATED]-(user:User) + WHERE m.id IN $messageIds AND NOT user.id = $currentUserId + SET m.seen = true + RETURN m { .* } + ` + const setSeenTxResponse = await transaction.run(setSeenCypher, { + messageIds, + currentUserId, + }) + const messages = await setSeenTxResponse.records.map((record) => record.get('m')) + return messages + }) + try { + await writeTxResultPromise + // send subscription to author to updated the messages + return true + } finally { + session.close() + } + }, }, Message: { ...Resolver('Message', { diff --git a/backend/src/schema/types/type/Message.gql b/backend/src/schema/types/type/Message.gql index 4a3346079..671c5523a 100644 --- a/backend/src/schema/types/type/Message.gql +++ b/backend/src/schema/types/type/Message.gql @@ -2,8 +2,14 @@ # room: _RoomFilter # } +enum _MessageOrdering { + createdAt_asc + createdAt_desc +} + type Message { id: ID! + indexId: Int! createdAt: String updatedAt: String @@ -16,6 +22,10 @@ type Message { username: String! @cypher(statement: "MATCH (this)<-[:CREATED]-(user:User) RETURN user.name") avatar: String @cypher(statement: "MATCH (this)<-[:CREATED]-(:User)-[:AVATAR_IMAGE]->(image:Image) RETURN image.url") date: String! @cypher(statement: "RETURN this.createdAt") + + saved: Boolean + distributed: Boolean + seen: Boolean } type Mutation { @@ -23,8 +33,15 @@ type Mutation { roomId: ID! content: String! ): Message + + MarkMessagesAsSeen(messageIds: [String!]): Boolean } type Query { - Message(roomId: ID!): [Message] + Message( + roomId: ID!, + first: Int + offset: Int + orderBy: [_MessageOrdering] + ): [Message] } diff --git a/webapp/components/Chat/Chat.vue b/webapp/components/Chat/Chat.vue index cca6c4319..95bf5da95 100644 --- a/webapp/components/Chat/Chat.vue +++ b/webapp/components/Chat/Chat.vue @@ -8,6 +8,7 @@ :template-actions="JSON.stringify(templatesText)" :menu-actions="JSON.stringify(menuActions)" :text-messages="JSON.stringify(textMessages)" + :message-actions="messageActions" :messages="JSON.stringify(messages)" :messages-loaded="messagesLoaded" :rooms="JSON.stringify(rooms)" @@ -21,6 +22,7 @@ @fetch-messages="fetchMessages($event.detail[0])" :responsive-breakpoint="responsiveBreakpoint" :single-room="singleRoom" + show-reaction-emojis="false" @show-demo-options="showDemoOptions = $event" >