diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml
index 74ebd1c43..e37b9fed0 100644
--- a/.github/workflows/test-e2e.yml
+++ b/.github/workflows/test-e2e.yml
@@ -2,8 +2,58 @@ name: ocelot.social end-to-end test CI
on: push
jobs:
+ docker_preparation:
+ name: Fullstack test preparation
+ runs-on: ubuntu-latest
+ outputs:
+ pr-number: ${{ steps.pr.outputs.number }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Copy env files
+ run: |
+ cp webapp/.env.template webapp/.env
+ cp backend/.env.template backend/.env
+
+ - name: Build docker images
+ run: |
+ mkdir /tmp/images
+ docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/
+ docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/images/neo4j.tar
+ docker build --target test -t "ocelotsocialnetwork/backend:test" backend/
+ docker save "ocelotsocialnetwork/backend:test" > /tmp/images/backend.tar
+ docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/
+ docker save "ocelotsocialnetwork/webapp:test" > /tmp/images/webapp.tar
+
+ - name: Install cypress requirements
+ run: |
+ wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386"
+ cd backend
+ yarn install
+ yarn build
+ cd ..
+ yarn install
+
+ - name: Get pr number
+ id: pr
+ uses: 8BitJonny/gh-get-current-pr@2.2.0
+
+ - name: Cache docker images
+ id: cache
+ uses: actions/cache/save@v3.3.1
+ with:
+ path: |
+ /opt/cucumber-json-formatter
+ /home/runner/.cache/Cypress
+ /home/runner/work/Ocelot-Social/Ocelot-Social
+ /tmp/images/
+ key: e2e-preparation-cache-pr${{ steps.pr.outputs.number }}
+
fullstack_tests:
name: Fullstack tests
+ if: success()
+ needs: docker_preparation
runs-on: ubuntu-latest
env:
jobs: 8
@@ -12,28 +62,27 @@ jobs:
# run copies of the current job in parallel
job: [1, 2, 3, 4, 5, 6, 7, 8]
steps:
- - name: Checkout code
- uses: actions/checkout@v3
+ - name: Restore cache
+ uses: actions/cache/restore@v3.3.1
+ id: cache
+ with:
+ path: |
+ /opt/cucumber-json-formatter
+ /home/runner/.cache/Cypress
+ /home/runner/work/Ocelot-Social/Ocelot-Social
+ /tmp/images/
+ key: e2e-preparation-cache-pr${{ needs.docker_preparation.outputs.pr-number }}
+ fail-on-cache-miss: true
- - name: webapp | copy env file
- run: cp webapp/.env.template webapp/.env
-
- - name: backend | copy env file
- run: cp backend/.env.template backend/.env
-
- - name: boot up test system | docker-compose
- run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
-
- - name: Full stack tests | prepare
+ - name: Boot up test system | docker-compose
run: |
- wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386"
chmod +x /opt/cucumber-json-formatter
sudo ln -fs /opt/cucumber-json-formatter /usr/bin/cucumber-json-formatter
- cd backend
- yarn install
- yarn build
- cd ..
- yarn install
+ docker load < /tmp/images/neo4j.tar
+ docker load < /tmp/images/backend.tar
+ docker load < /tmp/images/webapp.tar
+ docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
+ sleep 90s
- name: Full stack tests | run tests
id: e2e-tests
@@ -44,17 +93,26 @@ jobs:
run: |
cd cypress/
node create-cucumber-html-report.js
-
- - name: End-to-end tests | if tests failed, get pr number
- id: pr
- if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
- uses: 8BitJonny/gh-get-current-pr@2.2.0
- - name: End-to-end tests | if tests failed, upload report
+ - name: Full stack tests | if tests failed, upload report
id: e2e-report
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
uses: actions/upload-artifact@v3
with:
- name: ocelot-e2e-test-report-pr${{ steps.pr.outputs.number }}
+ name: ocelot-e2e-test-report-pr${{ needs.docker_preparation.outputs.pr-number }}
path: /home/runner/work/Ocelot-Social/Ocelot-Social/cypress/reports/cucumber_html_report
+ cleanup:
+ name: Cleanup
+ if: always()
+ needs: [docker_preparation, fullstack_tests]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Delete cache
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh extension install actions/gh-actions-cache
+ set +e
+ KEY="e2e-preparation-cache-pr${{ needs.docker_preparation.outputs.pr-number }}"
+ gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm
\ No newline at end of file
diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts
index a717ff7a6..7286683dd 100644
--- a/backend/src/db/seed.ts
+++ b/backend/src/db/seed.ts
@@ -11,6 +11,8 @@ import {
changeGroupMemberRoleMutation,
} from '../graphql/groups'
import { createPostMutation } from '../graphql/posts'
+import { createRoomMutation } from '../graphql/rooms'
+import { createMessageMutation } from '../graphql/messages'
import { createCommentMutation } from '../graphql/comments'
import { categories } from '../constants/categories'
@@ -1553,6 +1555,90 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
)
await Factory.build('donations')
+
+ // Chat
+ authenticatedUser = await huey.toJson()
+ const { data: roomHueyPeter } = await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: (await peterLustig.toJson()).id,
+ },
+ })
+
+ for (let i = 0; i < 30; i++) {
+ authenticatedUser = await huey.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: roomHueyPeter?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ authenticatedUser = await peterLustig.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: roomHueyPeter?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ }
+
+ authenticatedUser = await huey.toJson()
+ const { data: roomHueyJenny } = await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: (await jennyRostock.toJson()).id,
+ },
+ })
+ for (let i = 0; i < 1000; i++) {
+ authenticatedUser = await huey.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: roomHueyJenny?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ authenticatedUser = await jennyRostock.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: roomHueyJenny?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ }
+
+ for (const user of additionalUsers) {
+ authenticatedUser = await jennyRostock.toJson()
+ const { data: room } = await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: (await user.toJson()).id,
+ },
+ })
+
+ for (let i = 0; i < 29; i++) {
+ authenticatedUser = await jennyRostock.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: room?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ authenticatedUser = await user.toJson()
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: room?.CreateRoom.id,
+ content: faker.lorem.sentence(),
+ },
+ })
+ }
+ }
+
/* eslint-disable-next-line no-console */
console.log('Seeded Data...')
await driver.close()
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 109bf1d55..294b50641 100644
--- a/backend/src/graphql/rooms.ts
+++ b/backend/src/graphql/rooms.ts
@@ -6,18 +6,9 @@ export const createRoomMutation = () => {
CreateRoom(userId: $userId) {
id
roomId
- }
- }
- `
-}
-
-export const roomQuery = () => {
- return gql`
- query {
- Room {
- id
- roomId
roomName
+ lastMessageAt
+ unreadCount
users {
_id
id
@@ -30,3 +21,45 @@ export const roomQuery = () => {
}
`
}
+
+export const roomQuery = () => {
+ return gql`
+ query Room($first: Int, $offset: Int, $id: ID) {
+ Room(first: $first, offset: $offset, id: $id, orderBy: createdAt_desc) {
+ id
+ roomId
+ roomName
+ lastMessageAt
+ unreadCount
+ lastMessage {
+ _id
+ id
+ content
+ senderId
+ username
+ avatar
+ date
+ saved
+ distributed
+ seen
+ }
+ users {
+ _id
+ id
+ name
+ avatar {
+ url
+ }
+ }
+ }
+ }
+ `
+}
+
+export const unreadRoomsQuery = () => {
+ return gql`
+ query {
+ UnreadRooms
+ }
+ `
+}
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/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts
index c07098a3c..f87f4b079 100644
--- a/backend/src/middleware/permissionsMiddleware.ts
+++ b/backend/src/middleware/permissionsMiddleware.ts
@@ -408,6 +408,7 @@ export default shield(
getInviteCode: isAuthenticated, // and inviteRegistration
Room: isAuthenticated,
Message: isAuthenticated,
+ UnreadRooms: isAuthenticated,
},
Mutation: {
'*': deny,
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 03c3d4456..ee291a6c9 100644
--- a/backend/src/schema/resolvers/rooms.spec.ts
+++ b/backend/src/schema/resolvers/rooms.spec.ts
@@ -1,7 +1,8 @@
import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '../../db/factories'
import { getNeode, getDriver } from '../../db/neo4j'
-import { createRoomMutation, roomQuery } from '../../graphql/rooms'
+import { createRoomMutation, roomQuery, unreadRoomsQuery } from '../../graphql/rooms'
+import { createMessageMutation } from '../../graphql/messages'
import createServer from '../../server'
const driver = getDriver()
@@ -21,6 +22,9 @@ beforeAll(async () => {
driver,
neode,
user: authenticatedUser,
+ cypherParams: {
+ currentUserId: authenticatedUser ? authenticatedUser.id : null,
+ },
}
},
})
@@ -34,6 +38,8 @@ afterAll(async () => {
})
describe('Room', () => {
+ let roomId: string
+
beforeAll(async () => {
;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
Factory.build('user', {
@@ -48,6 +54,14 @@ describe('Room', () => {
id: 'not-chatting-user',
name: 'Not Chatting User',
}),
+ Factory.build('user', {
+ id: 'second-chatting-user',
+ name: 'Second Chatting User',
+ }),
+ Factory.build('user', {
+ id: 'third-chatting-user',
+ name: 'Third Chatting User',
+ }),
])
})
@@ -68,8 +82,6 @@ describe('Room', () => {
})
describe('authenticated', () => {
- let roomId: string
-
beforeAll(async () => {
authenticatedUser = await chattingUser.toJson()
})
@@ -122,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),
+ },
+ },
+ ]),
},
},
})
@@ -219,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',
@@ -260,4 +293,312 @@ describe('Room', () => {
})
})
})
+
+ describe('unread rooms query', () => {
+ describe('unauthenticated', () => {
+ it('throws authorization error', async () => {
+ authenticatedUser = null
+ await expect(
+ query({
+ query: unreadRoomsQuery(),
+ }),
+ ).resolves.toMatchObject({
+ errors: [{ message: 'Not Authorized!' }],
+ })
+ })
+ })
+
+ describe('authenticated', () => {
+ let otherRoomId: string
+
+ beforeAll(async () => {
+ authenticatedUser = await chattingUser.toJson()
+ const result = await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: 'not-chatting-user',
+ },
+ })
+ otherRoomId = result.data.CreateRoom.roomId
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: otherRoomId,
+ content: 'Message to not chatting user',
+ },
+ })
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId,
+ content: '1st message to other chatting user',
+ },
+ })
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId,
+ content: '2nd message to other chatting user',
+ },
+ })
+ authenticatedUser = await otherChattingUser.toJson()
+ const result2 = await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: 'not-chatting-user',
+ },
+ })
+ otherRoomId = result2.data.CreateRoom.roomId
+ await mutate({
+ mutation: createMessageMutation(),
+ variables: {
+ roomId: otherRoomId,
+ content: 'Other message to not chatting user',
+ },
+ })
+ })
+
+ describe('as chatting user', () => {
+ it('has 0 unread rooms', async () => {
+ authenticatedUser = await chattingUser.toJson()
+ await expect(
+ query({
+ query: unreadRoomsQuery(),
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UnreadRooms: 0,
+ },
+ })
+ })
+ })
+
+ describe('as other chatting user', () => {
+ it('has 1 unread rooms', async () => {
+ authenticatedUser = await otherChattingUser.toJson()
+ await expect(
+ query({
+ query: unreadRoomsQuery(),
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UnreadRooms: 1,
+ },
+ })
+ })
+ })
+
+ describe('as not chatting user', () => {
+ it('has 2 unread rooms', async () => {
+ authenticatedUser = await notChattingUser.toJson()
+ await expect(
+ query({
+ query: unreadRoomsQuery(),
+ }),
+ ).resolves.toMatchObject({
+ data: {
+ UnreadRooms: 2,
+ },
+ })
+ })
+ })
+ })
+ })
+
+ describe('query several rooms', () => {
+ beforeAll(async () => {
+ authenticatedUser = await chattingUser.toJson()
+ await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: 'second-chatting-user',
+ },
+ })
+ await mutate({
+ mutation: createRoomMutation(),
+ variables: {
+ userId: 'third-chatting-user',
+ },
+ })
+ })
+
+ it('returns the rooms paginated', async () => {
+ expect(await query({ query: roomQuery(), variables: { first: 3, offset: 0 } })).toMatchObject(
+ {
+ errors: undefined,
+ data: {
+ Room: [
+ {
+ id: expect.any(String),
+ roomId: expect.any(String),
+ roomName: 'Third Chatting User',
+ users: expect.arrayContaining([
+ {
+ _id: 'chatting-user',
+ id: 'chatting-user',
+ name: 'Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ {
+ _id: 'third-chatting-user',
+ id: 'third-chatting-user',
+ name: 'Third Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ ]),
+ },
+ {
+ id: expect.any(String),
+ roomId: expect.any(String),
+ roomName: 'Second Chatting User',
+ users: expect.arrayContaining([
+ {
+ _id: 'chatting-user',
+ id: 'chatting-user',
+ name: 'Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ {
+ _id: 'second-chatting-user',
+ id: 'second-chatting-user',
+ name: 'Second Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ ]),
+ },
+ {
+ id: expect.any(String),
+ roomId: expect.any(String),
+ roomName: 'Not Chatting User',
+ users: expect.arrayContaining([
+ {
+ _id: 'chatting-user',
+ id: 'chatting-user',
+ name: 'Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ {
+ _id: 'not-chatting-user',
+ id: 'not-chatting-user',
+ name: 'Not Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ ]),
+ },
+ ],
+ },
+ },
+ )
+ expect(await query({ query: roomQuery(), variables: { first: 3, offset: 3 } })).toMatchObject(
+ {
+ errors: undefined,
+ data: {
+ Room: [
+ {
+ id: expect.any(String),
+ roomId: expect.any(String),
+ roomName: 'Other Chatting User',
+ 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),
+ },
+ },
+ ]),
+ },
+ ],
+ },
+ },
+ )
+ })
+ })
+
+ describe('query single room', () => {
+ let result: any = null
+ beforeAll(async () => {
+ authenticatedUser = await chattingUser.toJson()
+ result = await query({ query: roomQuery() })
+ })
+ describe('as chatter of room', () => {
+ it('returns the room', async () => {
+ expect(
+ await query({
+ query: roomQuery(),
+ variables: { first: 2, offset: 0, id: result.data.Room[0].id },
+ }),
+ ).toMatchObject({
+ errors: undefined,
+ data: {
+ Room: [
+ {
+ id: expect.any(String),
+ roomId: expect.any(String),
+ roomName: 'Third Chatting User',
+ users: expect.arrayContaining([
+ {
+ _id: 'chatting-user',
+ id: 'chatting-user',
+ name: 'Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ {
+ _id: 'third-chatting-user',
+ id: 'third-chatting-user',
+ name: 'Third Chatting User',
+ avatar: {
+ url: expect.any(String),
+ },
+ },
+ ]),
+ },
+ ],
+ },
+ })
+ })
+ describe('as not chatter of room', () => {
+ beforeAll(async () => {
+ authenticatedUser = await notChattingUser.toJson()
+ })
+ it('returns no room', async () => {
+ authenticatedUser = await notChattingUser.toJson()
+ expect(
+ await query({
+ query: roomQuery(),
+ variables: { first: 2, offset: 0, id: result.data.Room[0].id },
+ }),
+ ).toMatchObject({
+ errors: undefined,
+ data: {
+ Room: [],
+ },
+ })
+ })
+ })
+ })
+ })
})
diff --git a/backend/src/schema/resolvers/rooms.ts b/backend/src/schema/resolvers/rooms.ts
index d5015a03b..5e931a446 100644
--- a/backend/src/schema/resolvers/rooms.ts
+++ b/backend/src/schema/resolvers/rooms.ts
@@ -1,29 +1,50 @@
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 neo4jgraphql(object, params, context, resolveInfo)
+ },
+ UnreadRooms: async (object, params, context, resolveInfo) => {
+ const {
+ user: { id: currentUserId },
+ } = context
+ const session = context.driver.session()
+ try {
+ const count = await getUnreadRoomsCount(currentUserId, session)
+ return count
+ } finally {
+ session.close()
}
- return resolved
},
},
Mutation: {
@@ -44,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,
@@ -68,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 2ce6556f6..fdce6865b 100644
--- a/backend/src/schema/types/type/Room.gql
+++ b/backend/src/schema/types/type/Room.gql
@@ -5,6 +5,11 @@
# users_some: _UserFilter
# }
+# TODO change this to last message date
+enum _RoomOrdering {
+ createdAt_desc
+}
+
type Room {
id: ID!
createdAt: String
@@ -13,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 {
@@ -24,5 +49,13 @@ type Mutation {
}
type Query {
- Room: [Room]
+ Room(
+ id: ID
+ orderBy: [_RoomOrdering]
+ ): [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 95bf5da95..43994ef5d 100644
--- a/webapp/components/Chat/Chat.vue
+++ b/webapp/components/Chat/Chat.vue
@@ -13,13 +13,15 @@
:messages-loaded="messagesLoaded"
:rooms="JSON.stringify(rooms)"
:room-actions="JSON.stringify(roomActions)"
- :rooms-loaded="true"
+ :rooms-loaded="roomsLoaded"
+ :loading-rooms="loadingRooms"
show-files="false"
show-audio="false"
:styles="JSON.stringify(computedChatStyle)"
:show-footer="true"
@send-message="sendMessage($event.detail[0])"
@fetch-messages="fetchMessages($event.detail[0])"
+ @fetch-more-rooms="fetchRooms"
:responsive-breakpoint="responsiveBreakpoint"
:single-room="singleRoom"
show-reaction-emojis="false"
@@ -58,10 +60,10 @@