diff --git a/backend/src/graphql/messages.ts b/backend/src/graphql/messages.ts index ca5ffb952..fde45083b 100644 --- a/backend/src/graphql/messages.ts +++ b/backend/src/graphql/messages.ts @@ -6,6 +6,10 @@ export const createMessageMutation = () => { CreateMessage(roomId: $roomId, content: $content) { id content + senderId + username + avatar + date saved distributed seen @@ -17,7 +21,7 @@ export const createMessageMutation = () => { export const messageQuery = () => { return gql` query ($roomId: ID!, $first: Int, $offset: Int) { - Message(roomId: $roomId, first: $first, offset: $offset, orderBy: createdAt_desc) { + Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) { _id id indexId diff --git a/backend/src/graphql/rooms.ts b/backend/src/graphql/rooms.ts index c9d3f54f2..294b50641 100644 --- a/backend/src/graphql/rooms.ts +++ b/backend/src/graphql/rooms.ts @@ -6,6 +6,17 @@ export const createRoomMutation = () => { CreateRoom(userId: $userId) { id roomId + roomName + lastMessageAt + unreadCount + users { + _id + id + name + avatar { + url + } + } } } ` @@ -18,6 +29,20 @@ export const roomQuery = () => { id roomId roomName + lastMessageAt + unreadCount + lastMessage { + _id + id + content + senderId + username + avatar + date + saved + distributed + seen + } users { _id id diff --git a/backend/src/middleware/chatMiddleware.ts b/backend/src/middleware/chatMiddleware.ts new file mode 100644 index 000000000..c28d6a70d --- /dev/null +++ b/backend/src/middleware/chatMiddleware.ts @@ -0,0 +1,57 @@ +import { isArray } from 'lodash' + +const setRoomProps = (room) => { + if (room.users) { + room.users.forEach((user) => { + user._id = user.id + }) + } + if (room.lastMessage) { + room.lastMessage._id = room.lastMessage.id + } +} + +const setMessageProps = (message, context) => { + message._id = message.id + if (message.senderId !== context.user.id) { + message.distributed = true + } +} + +const roomProperties = async (resolve, root, args, context, info) => { + const resolved = await resolve(root, args, context, info) + if (resolved) { + if (isArray(resolved)) { + resolved.forEach((room) => { + setRoomProps(room) + }) + } else { + setRoomProps(resolved) + } + } + return resolved +} + +const messageProperties = async (resolve, root, args, context, info) => { + const resolved = await resolve(root, args, context, info) + if (resolved) { + if (isArray(resolved)) { + resolved.forEach((message) => { + setMessageProps(message, context) + }) + } else { + setMessageProps(resolved, context) + } + } + return resolved +} + +export default { + Query: { + Room: roomProperties, + Message: messageProperties, + }, + Mutation: { + CreateRoom: roomProperties, + }, +} diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 813bbe9a7..08c872db7 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -14,6 +14,7 @@ import login from './login/loginMiddleware' import sentry from './sentryMiddleware' import languages from './languages/languages' import userInteractions from './userInteractions' +import chatMiddleware from './chatMiddleware' export default (schema) => { const middlewares = { @@ -31,6 +32,7 @@ export default (schema) => { orderBy, languages, userInteractions, + chatMiddleware, } let order = [ @@ -49,6 +51,7 @@ export default (schema) => { 'softDelete', 'includedFields', 'orderBy', + 'chatMiddleware', ] // add permisions middleware at the first position (unless we're seeding) diff --git a/backend/src/schema/resolvers/messages.spec.ts b/backend/src/schema/resolvers/messages.spec.ts index d0f1d7871..1679b0c34 100644 --- a/backend/src/schema/resolvers/messages.spec.ts +++ b/backend/src/schema/resolvers/messages.spec.ts @@ -1,13 +1,15 @@ import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../../db/factories' import { getNeode, getDriver } from '../../db/neo4j' -import { createRoomMutation } from '../../graphql/rooms' +import { createRoomMutation, roomQuery } from '../../graphql/rooms' import { createMessageMutation, messageQuery, markMessagesAsSeen } from '../../graphql/messages' -import createServer from '../../server' +import createServer, { pubsub } from '../../server' const driver = getDriver() const neode = getNeode() +const pubsubSpy = jest.spyOn(pubsub, 'publish') + let query let mutate let authenticatedUser @@ -22,6 +24,9 @@ beforeAll(async () => { driver, neode, user: authenticatedUser, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, } }, }) @@ -55,6 +60,10 @@ describe('Message', () => { }) describe('create message', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + describe('unauthenticated', () => { it('throws authorization error', async () => { await expect( @@ -77,7 +86,7 @@ describe('Message', () => { }) describe('room does not exist', () => { - it('returns null', async () => { + it('returns null and does not publish subscription', async () => { await expect( mutate({ mutation: createMessageMutation(), @@ -92,6 +101,7 @@ describe('Message', () => { CreateMessage: null, }, }) + expect(pubsubSpy).not.toBeCalled() }) }) @@ -107,7 +117,7 @@ describe('Message', () => { }) describe('user chats in room', () => { - it('returns the message', async () => { + it('returns the message and publishes subscription', async () => { await expect( mutate({ mutation: createMessageMutation(), @@ -122,12 +132,78 @@ describe('Message', () => { CreateMessage: { id: expect.any(String), 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: false, seen: false, }, }, }) + expect(pubsubSpy).toBeCalledWith('ROOM_COUNT_UPDATED', { + roomCountUpdated: '1', + userId: 'other-chatting-user', + }) + }) + + describe('room is updated as well', () => { + it('has last message set', async () => { + const result = await query({ query: roomQuery() }) + await expect(result).toMatchObject({ + errors: undefined, + data: { + Room: [ + expect.objectContaining({ + lastMessageAt: expect.any(String), + unreadCount: 0, + lastMessage: expect.objectContaining({ + _id: result.data.Room[0].lastMessage.id, + id: expect.any(String), + 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: false, + seen: false, + }), + }), + ], + }, + }) + }) + }) + + describe('unread count for other user', () => { + it('has unread count = 1', async () => { + authenticatedUser = await otherChattingUser.toJson() + await expect(query({ query: roomQuery() })).resolves.toMatchObject({ + errors: undefined, + data: { + Room: [ + expect.objectContaining({ + lastMessageAt: expect.any(String), + unreadCount: 1, + lastMessage: expect.objectContaining({ + _id: expect.any(String), + id: expect.any(String), + 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: false, + seen: false, + }), + }), + ], + }, + }) + }) }) }) diff --git a/backend/src/schema/resolvers/messages.ts b/backend/src/schema/resolvers/messages.ts index a9937aac4..a908f3fd8 100644 --- a/backend/src/schema/resolvers/messages.ts +++ b/backend/src/schema/resolvers/messages.ts @@ -1,5 +1,22 @@ import { neo4jgraphql } from 'neo4j-graphql-js' import Resolver from './helpers/Resolver' +import { getUnreadRoomsCount } from './rooms' +import { pubsub, ROOM_COUNT_UPDATED } from '../../server' + +const setMessagesAsDistributed = async (undistributedMessagesIds, session) => { + return 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 + }) +} export default { Query: { @@ -20,33 +37,15 @@ export default { 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() + const session = context.driver.session() + try { + if (undistributedMessagesIds.length > 0) { + await setMessagesAsDistributed(undistributedMessagesIds, session) } - // send subscription to author to updated the messages + } finally { + session.close() } - resolved.forEach((message) => { - message._id = message.id - if (message.senderId !== context.user.id) { - message.distributed = true - } - }) + // send subscription to author to updated the messages } return resolved.reverse() }, @@ -61,8 +60,11 @@ export default { const writeTxResultPromise = session.writeTransaction(async (transaction) => { const createMessageCypher = ` MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId }) + OPTIONAL MATCH (currentUser)-[:AVATAR_IMAGE]->(image:Image) OPTIONAL MATCH (m:Message)-[:INSIDE]->(room) - WITH MAX(m.indexId) as maxIndex, room, currentUser + OPTIONAL MATCH (room)<-[:CHATS_IN]-(recipientUser:User) + WHERE NOT recipientUser.id = $currentUserId + WITH MAX(m.indexId) as maxIndex, room, currentUser, image, recipientUser CREATE (currentUser)-[:CREATED]->(message:Message { createdAt: toString(datetime()), id: apoc.create.uuid(), @@ -72,20 +74,40 @@ export default { distributed: false, seen: false })-[:INSIDE]->(room) - RETURN message { .* } + SET room.lastMessageAt = toString(datetime()) + RETURN message { + .*, + recipientId: recipientUser.id, + senderId: currentUser.id, + username: currentUser.name, + avatar: image.url, + date: message.createdAt + } ` const createMessageTxResponse = await transaction.run(createMessageCypher, { currentUserId, roomId, content, }) + const [message] = await createMessageTxResponse.records.map((record) => record.get('message'), ) + return message }) try { const message = await writeTxResultPromise + if (message) { + const roomCountUpdated = await getUnreadRoomsCount(message.recipientId, session) + + // send subscriptions + await pubsub.publish(ROOM_COUNT_UPDATED, { + roomCountUpdated, + userId: message.recipientId, + }) + } + return message } catch (error) { throw new Error(error) diff --git a/backend/src/schema/resolvers/rooms.spec.ts b/backend/src/schema/resolvers/rooms.spec.ts index 690572e43..ee291a6c9 100644 --- a/backend/src/schema/resolvers/rooms.spec.ts +++ b/backend/src/schema/resolvers/rooms.spec.ts @@ -22,6 +22,9 @@ beforeAll(async () => { driver, neode, user: authenticatedUser, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, } }, }) @@ -131,6 +134,26 @@ describe('Room', () => { CreateRoom: { id: expect.any(String), roomId: result.data.CreateRoom.id, + roomName: 'Other Chatting User', + unreadCount: 0, + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'other-chatting-user', + id: 'other-chatting-user', + name: 'Other Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), }, }, }) @@ -228,6 +251,7 @@ describe('Room', () => { id: expect.any(String), roomId: result.data.Room[0].id, roomName: 'Chatting User', + unreadCount: 0, users: expect.arrayContaining([ { _id: 'chatting-user', diff --git a/backend/src/schema/resolvers/rooms.ts b/backend/src/schema/resolvers/rooms.ts index cbf5bcd63..5e931a446 100644 --- a/backend/src/schema/resolvers/rooms.ts +++ b/backend/src/schema/resolvers/rooms.ts @@ -1,46 +1,46 @@ import { neo4jgraphql } from 'neo4j-graphql-js' import Resolver from './helpers/Resolver' +import { pubsub, ROOM_COUNT_UPDATED } from '../../server' +import { withFilter } from 'graphql-subscriptions' + +export const getUnreadRoomsCount = async (userId, session) => { + return session.readTransaction(async (transaction) => { + const unreadRoomsCypher = ` + MATCH (:User { id: $userId })-[:CHATS_IN]->(room:Room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User) + WHERE NOT sender.id = $userId AND NOT message.seen + RETURN toString(COUNT(DISTINCT room)) AS count + ` + const unreadRoomsTxResponse = await transaction.run(unreadRoomsCypher, { userId }) + return unreadRoomsTxResponse.records.map((record) => record.get('count'))[0] + }) +} export default { + Subscription: { + roomCountUpdated: { + subscribe: withFilter( + () => pubsub.asyncIterator(ROOM_COUNT_UPDATED), + (payload, variables) => { + return payload.userId === variables.userId + }, + ), + }, + }, Query: { Room: async (object, params, context, resolveInfo) => { if (!params.filter) params.filter = {} params.filter.users_some = { id: context.user.id, } - const resolved = await neo4jgraphql(object, params, context, resolveInfo) - if (resolved) { - resolved.forEach((room) => { - if (room.users) { - // buggy, you must query the username for this to function correctly - room.roomName = room.users.filter((user) => user.id !== context.user.id)[0].name - room.avatar = - room.users.filter((user) => user.id !== context.user.id)[0].avatar?.url || - 'default-avatar' - room.users.forEach((user) => { - user._id = user.id - }) - } - }) - } - return resolved + return neo4jgraphql(object, params, context, resolveInfo) }, UnreadRooms: async (object, params, context, resolveInfo) => { const { user: { id: currentUserId }, } = context const session = context.driver.session() - const readTxResultPromise = session.readTransaction(async (transaction) => { - const unreadRoomsCypher = ` - MATCH (:User { id: $currentUserId })-[:CHATS_IN]->(room:Room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User) - WHERE NOT sender.id = $currentUserId AND NOT message.seen - RETURN toString(COUNT(DISTINCT room)) AS count - ` - const unreadRoomsTxResponse = await transaction.run(unreadRoomsCypher, { currentUserId }) - return unreadRoomsTxResponse.records.map((record) => record.get('count'))[0] - }) try { - const count = await readTxResultPromise + const count = await getUnreadRoomsCount(currentUserId, session) return count } finally { session.close() @@ -65,7 +65,17 @@ export default { ON CREATE SET room.createdAt = toString(datetime()), room.id = apoc.create.uuid() - RETURN room { .* } + WITH room, user, currentUser + OPTIONAL MATCH (room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User) + WHERE NOT sender.id = $currentUserId AND NOT message.seen + WITH room, user, currentUser, message, + user.name AS roomName + RETURN room { + .*, + users: [properties(currentUser), properties(user)], + roomName: roomName, + unreadCount: toString(COUNT(DISTINCT message)) + } ` const createRommTxResponse = await transaction.run(createRoomCypher, { userId, @@ -89,6 +99,7 @@ export default { }, Room: { ...Resolver('Room', { + undefinedToNull: ['lastMessageAt'], hasMany: { users: '<-[:CHATS_IN]-(related:User)', }, diff --git a/backend/src/schema/types/type/Message.gql b/backend/src/schema/types/type/Message.gql index 671c5523a..764181dd9 100644 --- a/backend/src/schema/types/type/Message.gql +++ b/backend/src/schema/types/type/Message.gql @@ -3,8 +3,7 @@ # } enum _MessageOrdering { - createdAt_asc - createdAt_desc + indexId_desc } type Message { diff --git a/backend/src/schema/types/type/Room.gql b/backend/src/schema/types/type/Room.gql index 80f61c83a..fdce6865b 100644 --- a/backend/src/schema/types/type/Room.gql +++ b/backend/src/schema/types/type/Room.gql @@ -18,8 +18,28 @@ type Room { users: [User]! @relation(name: "CHATS_IN", direction: "IN") roomId: String! @cypher(statement: "RETURN this.id") - roomName: String! ## @cypher(statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user[0].name") - avatar: String! ## @cypher match not own user in users array + roomName: String! @cypher(statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user.name") + avatar: String @cypher(statement: """ + MATCH (this)<-[:CHATS_IN]-(user:User) + WHERE NOT user.id = $cypherParams.currentUserId + OPTIONAL MATCH (user)-[:AVATAR_IMAGE]->(image:Image) + RETURN image.url + """) + + lastMessageAt: String + + lastMessage: Message @cypher(statement: """ + MATCH (this)<-[:INSIDE]-(message:Message) + WITH message ORDER BY message.indexId DESC LIMIT 1 + RETURN message + """) + + unreadCount: Int @cypher(statement: """ + MATCH (this)<-[:INSIDE]-(message:Message)<-[:CREATED]-(user:User) + WHERE NOT user.id = $cypherParams.currentUserId + AND NOT message.seen + RETURN count(message) + """) } type Mutation { @@ -35,3 +55,7 @@ type Query { ): [Room] UnreadRooms: Int } + +type Subscription { + roomCountUpdated(userId: ID!): Int +} diff --git a/backend/src/server.ts b/backend/src/server.ts index b4d63c007..feceeb9eb 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -14,6 +14,8 @@ import bodyParser from 'body-parser' import { graphqlUploadExpress } from 'graphql-upload' export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED' +// export const CHAT_MESSAGE_ADDED = 'CHAT_MESSAGE_ADDED' +export const ROOM_COUNT_UPDATED = 'ROOM_COUNT_UPDATED' const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG let prodPubsub, devPubsub const options = { diff --git a/webapp/components/Chat/Chat.vue b/webapp/components/Chat/Chat.vue index dd39e7bcd..43994ef5d 100644 --- a/webapp/components/Chat/Chat.vue +++ b/webapp/components/Chat/Chat.vue @@ -60,10 +60,10 @@