Merge branch 'master' into 6534-chat-room-usertag

This commit is contained in:
mahula 2023-07-17 15:01:45 +02:00 committed by GitHub
commit 466f67445e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 902 additions and 179 deletions

View File

@ -2,8 +2,58 @@ name: ocelot.social end-to-end test CI
on: push on: push
jobs: 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: fullstack_tests:
name: Fullstack tests name: Fullstack tests
if: success()
needs: docker_preparation
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
jobs: 8 jobs: 8
@ -12,28 +62,27 @@ jobs:
# run copies of the current job in parallel # run copies of the current job in parallel
job: [1, 2, 3, 4, 5, 6, 7, 8] job: [1, 2, 3, 4, 5, 6, 7, 8]
steps: steps:
- name: Checkout code - name: Restore cache
uses: actions/checkout@v3 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 - name: Boot up test system | docker-compose
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
run: | 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 chmod +x /opt/cucumber-json-formatter
sudo ln -fs /opt/cucumber-json-formatter /usr/bin/cucumber-json-formatter sudo ln -fs /opt/cucumber-json-formatter /usr/bin/cucumber-json-formatter
cd backend docker load < /tmp/images/neo4j.tar
yarn install docker load < /tmp/images/backend.tar
yarn build docker load < /tmp/images/webapp.tar
cd .. docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend
yarn install sleep 90s
- name: Full stack tests | run tests - name: Full stack tests | run tests
id: e2e-tests id: e2e-tests
@ -44,17 +93,25 @@ jobs:
run: | run: |
cd cypress/ cd cypress/
node create-cucumber-html-report.js 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 id: e2e-report
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }} if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: 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 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
KEY="e2e-preparation-cache-pr${{ needs.docker_preparation.outputs.pr-number }}"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm

View File

@ -11,6 +11,8 @@ import {
changeGroupMemberRoleMutation, changeGroupMemberRoleMutation,
} from '../graphql/groups' } from '../graphql/groups'
import { createPostMutation } from '../graphql/posts' import { createPostMutation } from '../graphql/posts'
import { createRoomMutation } from '../graphql/rooms'
import { createMessageMutation } from '../graphql/messages'
import { createCommentMutation } from '../graphql/comments' import { createCommentMutation } from '../graphql/comments'
import { categories } from '../constants/categories' import { categories } from '../constants/categories'
@ -1553,6 +1555,90 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
) )
await Factory.build('donations') 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 */ /* eslint-disable-next-line no-console */
console.log('Seeded Data...') console.log('Seeded Data...')
await driver.close() await driver.close()

View File

@ -6,6 +6,10 @@ export const createMessageMutation = () => {
CreateMessage(roomId: $roomId, content: $content) { CreateMessage(roomId: $roomId, content: $content) {
id id
content content
senderId
username
avatar
date
saved saved
distributed distributed
seen seen
@ -17,7 +21,7 @@ export const createMessageMutation = () => {
export const messageQuery = () => { export const messageQuery = () => {
return gql` return gql`
query ($roomId: ID!, $first: Int, $offset: Int) { 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
id id
indexId indexId

View File

@ -6,18 +6,9 @@ export const createRoomMutation = () => {
CreateRoom(userId: $userId) { CreateRoom(userId: $userId) {
id id
roomId roomId
}
}
`
}
export const roomQuery = () => {
return gql`
query {
Room {
id
roomId
roomName roomName
lastMessageAt
unreadCount
users { users {
_id _id
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
}
`
}

View File

@ -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,
},
}

View File

@ -14,6 +14,7 @@ import login from './login/loginMiddleware'
import sentry from './sentryMiddleware' import sentry from './sentryMiddleware'
import languages from './languages/languages' import languages from './languages/languages'
import userInteractions from './userInteractions' import userInteractions from './userInteractions'
import chatMiddleware from './chatMiddleware'
export default (schema) => { export default (schema) => {
const middlewares = { const middlewares = {
@ -31,6 +32,7 @@ export default (schema) => {
orderBy, orderBy,
languages, languages,
userInteractions, userInteractions,
chatMiddleware,
} }
let order = [ let order = [
@ -49,6 +51,7 @@ export default (schema) => {
'softDelete', 'softDelete',
'includedFields', 'includedFields',
'orderBy', 'orderBy',
'chatMiddleware',
] ]
// add permisions middleware at the first position (unless we're seeding) // add permisions middleware at the first position (unless we're seeding)

View File

@ -408,6 +408,7 @@ export default shield(
getInviteCode: isAuthenticated, // and inviteRegistration getInviteCode: isAuthenticated, // and inviteRegistration
Room: isAuthenticated, Room: isAuthenticated,
Message: isAuthenticated, Message: isAuthenticated,
UnreadRooms: isAuthenticated,
}, },
Mutation: { Mutation: {
'*': deny, '*': deny,

View File

@ -1,7 +1,7 @@
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '../../db/factories' import Factory, { cleanDatabase } from '../../db/factories'
import { getNeode, getDriver } from '../../db/neo4j' 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 { createMessageMutation, messageQuery, markMessagesAsSeen } from '../../graphql/messages'
import createServer from '../../server' import createServer from '../../server'
@ -22,6 +22,9 @@ beforeAll(async () => {
driver, driver,
neode, neode,
user: authenticatedUser, user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
} }
}, },
}) })
@ -122,6 +125,10 @@ describe('Message', () => {
CreateMessage: { CreateMessage: {
id: expect.any(String), id: expect.any(String),
content: 'Some nice message to other chatting user', content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
saved: true, saved: true,
distributed: false, distributed: false,
seen: 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', () => { describe('user does not chat in room', () => {

View File

@ -41,12 +41,6 @@ export default {
} }
// send subscription to author to updated the messages // 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() return resolved.reverse()
}, },
@ -61,8 +55,9 @@ export default {
const writeTxResultPromise = session.writeTransaction(async (transaction) => { const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const createMessageCypher = ` const createMessageCypher = `
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId }) MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId })
OPTIONAL MATCH (currentUser)-[:AVATAR_IMAGE]->(image:Image)
OPTIONAL MATCH (m:Message)-[:INSIDE]->(room) OPTIONAL MATCH (m:Message)-[:INSIDE]->(room)
WITH MAX(m.indexId) as maxIndex, room, currentUser WITH MAX(m.indexId) as maxIndex, room, currentUser, image
CREATE (currentUser)-[:CREATED]->(message:Message { CREATE (currentUser)-[:CREATED]->(message:Message {
createdAt: toString(datetime()), createdAt: toString(datetime()),
id: apoc.create.uuid(), id: apoc.create.uuid(),
@ -72,7 +67,14 @@ export default {
distributed: false, distributed: false,
seen: false seen: false
})-[:INSIDE]->(room) })-[:INSIDE]->(room)
RETURN message { .* } SET room.lastMessageAt = toString(datetime())
RETURN message {
.*,
senderId: currentUser.id,
username: currentUser.name,
avatar: image.url,
date: message.createdAt
}
` `
const createMessageTxResponse = await transaction.run(createMessageCypher, { const createMessageTxResponse = await transaction.run(createMessageCypher, {
currentUserId, currentUserId,

View File

@ -1,7 +1,8 @@
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '../../db/factories' import Factory, { cleanDatabase } from '../../db/factories'
import { getNeode, getDriver } from '../../db/neo4j' 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' import createServer from '../../server'
const driver = getDriver() const driver = getDriver()
@ -21,6 +22,9 @@ beforeAll(async () => {
driver, driver,
neode, neode,
user: authenticatedUser, user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
} }
}, },
}) })
@ -34,6 +38,8 @@ afterAll(async () => {
}) })
describe('Room', () => { describe('Room', () => {
let roomId: string
beforeAll(async () => { beforeAll(async () => {
;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([ ;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
Factory.build('user', { Factory.build('user', {
@ -48,6 +54,14 @@ describe('Room', () => {
id: 'not-chatting-user', id: 'not-chatting-user',
name: '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', () => { describe('authenticated', () => {
let roomId: string
beforeAll(async () => { beforeAll(async () => {
authenticatedUser = await chattingUser.toJson() authenticatedUser = await chattingUser.toJson()
}) })
@ -122,6 +134,26 @@ describe('Room', () => {
CreateRoom: { CreateRoom: {
id: expect.any(String), id: expect.any(String),
roomId: result.data.CreateRoom.id, 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), id: expect.any(String),
roomId: result.data.Room[0].id, roomId: result.data.Room[0].id,
roomName: 'Chatting User', roomName: 'Chatting User',
unreadCount: 0,
users: expect.arrayContaining([ users: expect.arrayContaining([
{ {
_id: 'chatting-user', _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: [],
},
})
})
})
})
})
}) })

View File

@ -8,22 +8,28 @@ export default {
params.filter.users_some = { params.filter.users_some = {
id: context.user.id, id: context.user.id,
} }
const resolved = await neo4jgraphql(object, params, context, resolveInfo) return neo4jgraphql(object, params, context, resolveInfo)
if (resolved) { },
resolved.forEach((room) => { UnreadRooms: async (object, params, context, resolveInfo) => {
if (room.users) { const {
// buggy, you must query the username for this to function correctly user: { id: currentUserId },
room.roomName = room.users.filter((user) => user.id !== context.user.id)[0].name } = context
room.avatar = const session = context.driver.session()
room.users.filter((user) => user.id !== context.user.id)[0].avatar?.url || const readTxResultPromise = session.readTransaction(async (transaction) => {
'default-avatar' const unreadRoomsCypher = `
room.users.forEach((user) => { MATCH (:User { id: $currentUserId })-[:CHATS_IN]->(room:Room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User)
user._id = user.id 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
return count
} finally {
session.close()
} }
return resolved
}, },
}, },
Mutation: { Mutation: {
@ -44,7 +50,17 @@ export default {
ON CREATE SET ON CREATE SET
room.createdAt = toString(datetime()), room.createdAt = toString(datetime()),
room.id = apoc.create.uuid() 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, { const createRommTxResponse = await transaction.run(createRoomCypher, {
userId, userId,
@ -68,6 +84,7 @@ export default {
}, },
Room: { Room: {
...Resolver('Room', { ...Resolver('Room', {
undefinedToNull: ['lastMessageAt'],
hasMany: { hasMany: {
users: '<-[:CHATS_IN]-(related:User)', users: '<-[:CHATS_IN]-(related:User)',
}, },

View File

@ -3,8 +3,7 @@
# } # }
enum _MessageOrdering { enum _MessageOrdering {
createdAt_asc indexId_desc
createdAt_desc
} }
type Message { type Message {

View File

@ -5,6 +5,11 @@
# users_some: _UserFilter # users_some: _UserFilter
# } # }
# TODO change this to last message date
enum _RoomOrdering {
createdAt_desc
}
type Room { type Room {
id: ID! id: ID!
createdAt: String createdAt: String
@ -13,8 +18,23 @@ type Room {
users: [User]! @relation(name: "CHATS_IN", direction: "IN") users: [User]! @relation(name: "CHATS_IN", direction: "IN")
roomId: String! @cypher(statement: "RETURN this.id") 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") roomName: String! @cypher(statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user.name")
avatar: String! ## @cypher match not own user in users array 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 { type Mutation {
@ -24,5 +44,9 @@ type Mutation {
} }
type Query { type Query {
Room: [Room] Room(
id: ID
orderBy: [_RoomOrdering]
): [Room]
UnreadRooms: Int
} }

View File

@ -13,13 +13,15 @@
:messages-loaded="messagesLoaded" :messages-loaded="messagesLoaded"
:rooms="JSON.stringify(rooms)" :rooms="JSON.stringify(rooms)"
:room-actions="JSON.stringify(roomActions)" :room-actions="JSON.stringify(roomActions)"
:rooms-loaded="true" :rooms-loaded="roomsLoaded"
:loading-rooms="loadingRooms"
show-files="false" show-files="false"
show-audio="false" show-audio="false"
:styles="JSON.stringify(computedChatStyle)" :styles="JSON.stringify(computedChatStyle)"
:show-footer="true" :show-footer="true"
@send-message="sendMessage($event.detail[0])" @send-message="sendMessage($event.detail[0])"
@fetch-messages="fetchMessages($event.detail[0])" @fetch-messages="fetchMessages($event.detail[0])"
@fetch-more-rooms="fetchRooms"
:responsive-breakpoint="responsiveBreakpoint" :responsive-breakpoint="responsiveBreakpoint"
:single-room="singleRoom" :single-room="singleRoom"
show-reaction-emojis="false" show-reaction-emojis="false"
@ -118,7 +120,64 @@ export default {
text: 'This is the action', text: 'This is the action',
}, },
], ],
textMessages: { roomActions: [
/*
{
name: 'archiveRoom',
title: 'Archive Room',
},
{ name: 'inviteUser', title: 'Invite User' },
{ name: 'removeUser', title: 'Remove User' },
{ name: 'deleteRoom', title: 'Delete Room' },
*/
],
showDemoOptions: true,
responsiveBreakpoint: 600,
rooms: [],
roomsLoaded: false,
roomPage: 0,
roomPageSize: 10, // TODO pagination is a problem with single rooms - cant use
singleRoom: !!this.singleRoomId || false,
selectedRoom: null,
loadingRooms: true,
messagesLoaded: false,
messagePage: 0,
messagePageSize: 20,
messages: [],
}
},
mounted() {
if (this.singleRoom) {
this.$apollo
.mutate({
mutation: createRoom(),
variables: {
userId: this.singleRoomId,
},
})
.then(({ data: { CreateRoom } }) => {
this.fetchRooms({ room: CreateRoom })
})
.catch((error) => {
this.$toast.error(error)
})
.finally(() => {
// this.loading = false
})
} else {
this.fetchRooms()
}
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
computedChatStyle() {
return chatStyle.STYLE.light
},
textMessages() {
return {
ROOMS_EMPTY: this.$t('chat.roomsEmpty'), ROOMS_EMPTY: this.$t('chat.roomsEmpty'),
ROOM_EMPTY: this.$t('chat.roomEmpty'), ROOM_EMPTY: this.$t('chat.roomEmpty'),
NEW_MESSAGES: this.$t('chat.newMessages'), NEW_MESSAGES: this.$t('chat.newMessages'),
@ -131,67 +190,54 @@ export default {
LAST_SEEN: this.$t('chat.lastSeen'), LAST_SEEN: this.$t('chat.lastSeen'),
IS_TYPING: this.$t('chat.isTyping'), IS_TYPING: this.$t('chat.isTyping'),
CANCEL_SELECT_MESSAGE: this.$t('chat.cancelSelectMessage'), CANCEL_SELECT_MESSAGE: this.$t('chat.cancelSelectMessage'),
}, }
roomActions: [
/*
{
name: 'archiveRoom',
title: 'Archive Room',
},
{ name: 'inviteUser', title: 'Invite User' },
{ name: 'removeUser', title: 'Remove User' },
{ name: 'deleteRoom', title: 'Delete Room' },
*/
],
rooms: [],
messages: [],
messagesLoaded: true,
showDemoOptions: true,
responsiveBreakpoint: 600,
singleRoom: !!this.singleRoomId || false,
messagePage: 0,
messagePageSize: 20,
roomPage: 0,
roomPageSize: 999, // TODO pagination is a problem with single rooms - cant use
selectedRoom: null,
}
},
mounted() {
if (this.singleRoom) {
this.$apollo
.mutate({
mutation: createRoom(),
variables: {
userId: this.singleRoomId,
},
})
.then(() => {
this.$apollo.queries.Rooms.refetch()
})
.catch((error) => {
this.$toast.error(error)
})
.finally(() => {
// this.loading = false
})
}
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
computedChatStyle() {
// TODO light/dark theme still needed?
// return this.theme === 'light' ? chatStyle.STYLE.light : chatStyle.STYLE.dark
return chatStyle.STYLE.light
}, },
}, },
methods: { methods: {
async fetchRooms({ room } = {}) {
this.roomsLoaded = false
const offset = this.roomPage * this.roomPageSize
try {
const {
data: { Room },
} = await this.$apollo.query({
query: roomQuery(),
variables: {
id: room?.id,
first: this.roomPageSize,
offset,
},
fetchPolicy: 'no-cache',
})
const newRooms = Room.map((r) => {
return {
...r,
users: r.users.map((u) => {
return { ...u, username: u.name, avatar: u.avatar?.url }
}),
}
})
this.rooms = [...this.rooms, ...newRooms]
if (Room.length < this.roomPageSize) {
this.roomsLoaded = true
}
this.roomPage += 1
} catch (error) {
this.rooms = []
this.$toast.error(error.message)
}
// must be set false after initial rooms are loaded and never changed again
this.loadingRooms = false
},
async fetchMessages({ room, options = {} }) { async fetchMessages({ room, options = {} }) {
if (this.selectedRoom !== room.id) { if (this.selectedRoom?.id !== room.id) {
this.messages = [] this.messages = []
this.messagePage = 0 this.messagePage = 0
this.selectedRoom = room.id this.selectedRoom = room
} }
this.messagesLoaded = options.refetch ? this.messagesLoaded : false this.messagesLoaded = options.refetch ? this.messagesLoaded : false
const offset = (options.refetch ? 0 : this.messagePage) * this.messagePageSize const offset = (options.refetch ? 0 : this.messagePage) * this.messagePageSize
@ -210,6 +256,7 @@ export default {
const msgs = [] const msgs = []
;[...this.messages, ...Message].forEach((m) => { ;[...this.messages, ...Message].forEach((m) => {
m.date = new Date(m.date).toDateString()
msgs[m.indexId] = m msgs[m.indexId] = m
}) })
this.messages = msgs.filter(Boolean) this.messages = msgs.filter(Boolean)
@ -224,13 +271,6 @@ export default {
} }
}, },
refetchMessage(roomId) {
this.fetchMessages({
room: this.rooms.find((r) => r.roomId === roomId),
options: { refetch: true },
})
},
async sendMessage(message) { async sendMessage(message) {
// check for usersTag and change userid to username // check for usersTag and change userid to username
message.usersTag.forEach((userTag) => { message.usersTag.forEach((userTag) => {
@ -249,7 +289,10 @@ export default {
} catch (error) { } catch (error) {
this.$toast.error(error.message) this.$toast.error(error.message)
} }
this.refetchMessage(message.roomId) this.fetchMessages({
room: this.rooms.find((r) => r.roomId === message.roomId),
options: { refetch: true },
})
}, },
getInitialsName(fullname) { getInitialsName(fullname) {
@ -257,45 +300,6 @@ export default {
return fullname.match(/\b\w/g).join('').substring(0, 3).toUpperCase() return fullname.match(/\b\w/g).join('').substring(0, 3).toUpperCase()
}, },
}, },
apollo: {
Rooms: {
query() {
return roomQuery()
},
variables() {
return {
first: this.roomPageSize,
offset: this.roomPage * this.roomPageSize,
}
},
update({ Room }) {
if (!Room) {
this.rooms = []
return
}
// Backend result needs mapping of the following values
// room[i].users[j].name -> room[i].users[j].username
// room[i].users[j].avatar.url -> room[i].users[j].avatar
// also filter rooms for the single room
this.rooms = Room.map((r) => {
return {
...r,
users: r.users.map((u) => {
return { ...u, username: u.name, avatar: u.avatar?.url }
}),
}
}).filter((r) =>
this.singleRoom ? r.users.filter((u) => u.id === this.singleRoomId).length > 0 : true,
)
},
error(error) {
this.rooms = []
this.$toast.error(error.message)
},
fetchPolicy: 'no-cache',
},
},
} }
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@ -8,18 +8,34 @@
placement: 'bottom-start', placement: 'bottom-start',
}" }"
> >
<counter-icon icon="chat-bubble" :count="1" danger /> <counter-icon icon="chat-bubble" :count="count" danger />
</base-button> </base-button>
</nuxt-link> </nuxt-link>
</template> </template>
<script> <script>
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon' import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import { unreadRoomsQuery } from '~/graphql/Rooms'
export default { export default {
name: 'ChatNotificationMenu', name: 'ChatNotificationMenu',
components: { components: {
CounterIcon, CounterIcon,
}, },
data() {
return {
count: 0,
}
},
apollo: {
UnreadRooms: {
query() {
return unreadRoomsQuery()
},
update({ UnreadRooms }) {
this.count = UnreadRooms
},
},
},
} }
</script> </script>

View File

@ -3,15 +3,21 @@ import gql from 'graphql-tag'
export const messageQuery = () => { export const messageQuery = () => {
return gql` return gql`
query ($roomId: ID!, $first: Int, $offset: Int) { 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
id id
indexId indexId
senderId
content content
senderId
author { author {
id id
} }
username
avatar
date
saved
distributed
seen
} }
} }
` `

View File

@ -1,8 +1,8 @@
import gql from 'graphql-tag' import gql from 'graphql-tag'
export const roomQuery = () => gql` export const roomQuery = () => gql`
query Room($first: Int, $offset: Int) { query Room($first: Int, $offset: Int, $id: ID) {
Room(first: $first, offset: $offset) { Room(first: $first, offset: $offset, id: $id, orderBy: createdAt_desc) {
id id
roomId roomId
roomName roomName
@ -27,3 +27,11 @@ export const createRoom = () => gql`
} }
} }
` `
export const unreadRoomsQuery = () => {
return gql`
query {
UnreadRooms
}
`
}