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..628034be5 100644 --- a/backend/src/schema/resolvers/messages.spec.ts +++ b/backend/src/schema/resolvers/messages.spec.ts @@ -1,7 +1,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 { createRoomMutation, roomQuery } from '../../graphql/rooms' import { createMessageMutation, messageQuery, markMessagesAsSeen } from '../../graphql/messages' import createServer from '../../server' @@ -22,6 +22,9 @@ beforeAll(async () => { driver, neode, user: authenticatedUser, + cypherParams: { + currentUserId: authenticatedUser ? authenticatedUser.id : null, + }, } }, }) @@ -122,6 +125,10 @@ 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, @@ -129,6 +136,64 @@ describe('Message', () => { }, }) }) + + 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, + }), + }), + ], + }, + }) + }) + }) }) describe('user does not chat in room', () => { diff --git a/backend/src/schema/resolvers/messages.ts b/backend/src/schema/resolvers/messages.ts index ececd65a6..e1b988221 100644 --- a/backend/src/schema/resolvers/messages.ts +++ b/backend/src/schema/resolvers/messages.ts @@ -43,12 +43,6 @@ export default { } // 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.reverse() }, @@ -63,8 +57,9 @@ 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)<-[:CHATS_IN]-(otherUser:User) - WITH MAX(m.indexId) as maxIndex, room, currentUser, otherUser + WITH MAX(m.indexId) as maxIndex, room, currentUser, image, otherUser CREATE (currentUser)-[:CREATED]->(message:Message { createdAt: toString(datetime()), id: apoc.create.uuid(), @@ -74,7 +69,16 @@ export default { distributed: false, seen: false })-[:INSIDE]->(room) - RETURN message { .*, room: properties(room), senderId: currentUser.id, otherUser: properties(otherUser) } + SET room.lastMessageAt = toString(datetime()) + RETURN message { + .*, + room: properties(room) + otherUser: properties(otherUser) + senderId: currentUser.id, + username: currentUser.name, + avatar: image.url, + date: message.createdAt + } ` const createMessageTxResponse = await transaction.run(createMessageCypher, { currentUserId, 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 00d9d7b26..6774dad98 100644 --- a/backend/src/schema/resolvers/rooms.ts +++ b/backend/src/schema/resolvers/rooms.ts @@ -20,22 +20,7 @@ export default { 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 { @@ -77,7 +62,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, @@ -101,6 +96,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 580edcfac..2ffbc2b96 100644 --- a/backend/src/schema/types/type/Room.gql +++ b/backend/src/schema/types/type/Room.gql @@ -18,8 +18,23 @@ 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 RETURN user.avatar.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 { diff --git a/webapp/components/Chat/Chat.vue b/webapp/components/Chat/Chat.vue index dd39e7bcd..88d0b811c 100644 --- a/webapp/components/Chat/Chat.vue +++ b/webapp/components/Chat/Chat.vue @@ -120,20 +120,6 @@ export default { text: 'This is the action', }, ], - textMessages: { - ROOMS_EMPTY: this.$t('chat.roomsEmpty'), - ROOM_EMPTY: this.$t('chat.roomEmpty'), - NEW_MESSAGES: this.$t('chat.newMessages'), - MESSAGE_DELETED: this.$t('chat.messageDeleted'), - MESSAGES_EMPTY: this.$t('chat.messagesEmpty'), - CONVERSATION_STARTED: this.$t('chat.conversationStarted'), - TYPE_MESSAGE: this.$t('chat.typeMessage'), - SEARCH: this.$t('chat.search'), - IS_ONLINE: this.$t('chat.isOnline'), - LAST_SEEN: this.$t('chat.lastSeen'), - IS_TYPING: this.$t('chat.isTyping'), - CANCEL_SELECT_MESSAGE: this.$t('chat.cancelSelectMessage'), - }, roomActions: [ /* { @@ -190,6 +176,22 @@ export default { computedChatStyle() { return chatStyle.STYLE.light }, + textMessages() { + return { + ROOMS_EMPTY: this.$t('chat.roomsEmpty'), + ROOM_EMPTY: this.$t('chat.roomEmpty'), + NEW_MESSAGES: this.$t('chat.newMessages'), + MESSAGE_DELETED: this.$t('chat.messageDeleted'), + MESSAGES_EMPTY: this.$t('chat.messagesEmpty'), + CONVERSATION_STARTED: this.$t('chat.conversationStarted'), + TYPE_MESSAGE: this.$t('chat.typeMessage'), + SEARCH: this.$t('chat.search'), + IS_ONLINE: this.$t('chat.isOnline'), + LAST_SEEN: this.$t('chat.lastSeen'), + IS_TYPING: this.$t('chat.isTyping'), + CANCEL_SELECT_MESSAGE: this.$t('chat.cancelSelectMessage'), + } + }, }, methods: { async fetchRooms({ room } = {}) { diff --git a/webapp/graphql/Messages.js b/webapp/graphql/Messages.js index 1d7917bfc..26b9b516f 100644 --- a/webapp/graphql/Messages.js +++ b/webapp/graphql/Messages.js @@ -3,7 +3,7 @@ import gql from 'graphql-tag' 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