Merge branch 'master' of github.com:Ocelot-Social-Community/Ocelot-Social into 3086-avoid-red-color-for-non-essential-things--new

This commit is contained in:
Wolfgang Huß 2023-07-19 12:20:28 +02:00
commit 6beb59b89c
34 changed files with 2080 additions and 1054 deletions

View File

@ -1,4 +1,5 @@
backend: &backend
- '.github/workflows/test-backend.yml'
- 'backend/**/*'
- 'neo4j/**/*'
@ -6,4 +7,5 @@ docker: &docker
- 'docker-compose.*'
webapp: &webapp
- '.github/workflows/test-webapp.yml'
- 'webapp/**/*'

View File

@ -0,0 +1,42 @@
###############################################################################
# A Github repo has max 10 GB of cache.
# https://github.blog/changelog/2021-11-23-github-actions-cache-size-is-now-increased-to-10gb-per-repository/
#
# To avoid "cache thrashing" by their cache eviction policy it is recommended
# to apply a cache cleanup workflow at PR closing to dele cache leftovers of
# the current branch:
# https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
###############################################################################
name: ocelot.social cache cleanup on pr closing
on:
pull_request:
types:
- closed
jobs:
clean-branch-cache:
name: Cleanup branch cache
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
REPO=${{ github.repository }}
BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,7 +1,7 @@
name: ocelot.social backend test CI
on: [push]
on: push
jobs:
files-changed:
@ -13,7 +13,7 @@ jobs:
steps:
- uses: actions/checkout@v3.3.0
- name: Check for frontend file changes
- name: Check for backend file changes
uses: dorny/paths-filter@v2.11.1
id: changes
with:
@ -34,12 +34,13 @@ jobs:
run: |
docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/
docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/neo4j.tar
- name: Upload Artifact
uses: actions/upload-artifact@v3
- name: Cache docker images
id: cache-neo4j
uses: actions/cache/save@v3.3.1
with:
name: docker-neo4j-image
path: /tmp/neo4j.tar
key: ${{ github.run_id }}-backend-neo4j-cache
build_test_backend:
name: Docker Build Test - Backend
@ -54,12 +55,13 @@ jobs:
run: |
docker build --target test -t "ocelotsocialnetwork/backend:test" backend/
docker save "ocelotsocialnetwork/backend:test" > /tmp/backend.tar
- name: Upload Artifact
uses: actions/upload-artifact@v3
- name: Cache docker images
id: cache-backend
uses: actions/cache/save@v3.3.1
with:
name: docker-backend-test
path: /tmp/backend.tar
key: ${{ github.run_id }}-backend-cache
lint_backend:
name: Lint Backend
@ -84,28 +86,29 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- name: Download Docker Image (Neo4J)
uses: actions/download-artifact@v3
- name: Restore Neo4J cache
uses: actions/cache/restore@v3.3.1
with:
name: docker-neo4j-image
path: /tmp
path: /tmp/neo4j.tar
key: ${{ github.run_id }}-backend-neo4j-cache
fail-on-cache-miss: true
- name: Load Docker Image
run: docker load < /tmp/neo4j.tar
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v3
- name: Restore Backend cache
uses: actions/cache/restore@v3.3.1
with:
name: docker-backend-test
path: /tmp
path: /tmp/backend.tar
key: ${{ github.run_id }}-backend-cache
fail-on-cache-miss: true
- name: Load Docker Image
run: docker load < /tmp/backend.tar
- name: Load Docker Images
run: |
docker load < /tmp/neo4j.tar
docker load < /tmp/backend.tar
- name: backend | copy env files webapp
run: cp webapp/.env.template webapp/.env
- name: backend | copy env files backend
run: cp backend/.env.template backend/.env
- name: backend | copy env files
run: |
cp webapp/.env.template webapp/.env
cp backend/.env.template backend/.env
- name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend
@ -118,3 +121,20 @@ jobs:
- name: backend | Unit test incl. coverage check
run: docker-compose exec -T backend yarn test
cleanup:
name: Cleanup
if: ${{ needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.docker == 'true' }}
needs: [files-changed, unit_test_backend]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Delete cache
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh extension install actions/gh-actions-cache
KEY="${{ github.run_id }}-backend-neo4j-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm
KEY="${{ github.run_id }}-backend-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm

View File

@ -1,9 +1,54 @@
name: ocelot.social end-to-end test CI
on: push
jobs:
docker_preparation:
name: Fullstack test preparation
runs-on: ubuntu-latest
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: 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: ${{ github.run_id }}-e2e-preparation-cache
fullstack_tests:
name: Fullstack tests
if: success()
needs: docker_preparation
runs-on: ubuntu-latest
env:
jobs: 8
@ -12,28 +57,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: ${{ github.run_id }}-e2e-preparation-cache
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 +88,25 @@ 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
needs: [docker_preparation, fullstack_tests]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Delete cache
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh extension install actions/gh-actions-cache
KEY="${{ github.run_id }}-e2e-preparation-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm

View File

@ -1,7 +1,7 @@
name: ocelot.social webapp test CI
on: [push]
on: push
jobs:
files-changed:
@ -34,7 +34,7 @@ jobs:
run: |
scripts/translations/sort.sh
scripts/translations/missing-keys.sh
build_test_webapp:
name: Docker Build Test - Webapp
if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp == 'true'
@ -44,16 +44,16 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- name: webapp | Build 'test' image
- name: Webapp | Build 'test' image
run: |
docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/
docker save "ocelotsocialnetwork/webapp:test" > /tmp/webapp.tar
- name: Upload Artifact
uses: actions/upload-artifact@v3
- name: Cache docker image
uses: actions/cache/save@v3.3.1
with:
name: docker-webapp-test
path: /tmp/webapp.tar
key: ${{ github.run_id }}-webapp-cache
lint_webapp:
name: Lint Webapp
@ -78,20 +78,19 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- name: Download Docker Image (Webapp)
uses: actions/download-artifact@v3
- name: Restore webapp cache
uses: actions/cache/restore@v3.3.1
with:
name: docker-webapp-test
path: /tmp
path: /tmp/webapp.tar
key: ${{ github.run_id }}-webapp-cache
- name: Load Docker Image
run: docker load < /tmp/webapp.tar
- name: backend | copy env files webapp
run: cp webapp/.env.template webapp/.env
- name: backend | copy env files backend
run: cp backend/.env.template backend/.env
- name: Copy env files
run: |
cp webapp/.env.template webapp/.env
cp backend/.env.template backend/.env
- name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp
@ -99,3 +98,18 @@ jobs:
- name: webapp | Unit tests incl. coverage check
run: docker-compose exec -T webapp yarn test
cleanup:
name: Cleanup
if: ${{ needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp == 'true' }}
needs: [files-changed, unit_test_webapp]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Delete cache
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh extension install actions/gh-actions-cache
KEY="${{ github.run_id }}-webapp-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,10 @@ export const createMessageMutation = () => {
CreateMessage(roomId: $roomId, content: $content) {
id
content
senderId
username
avatar
date
saved
distributed
seen
@ -17,12 +21,15 @@ 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
content
senderId
author {
id
}
username
avatar
date

View File

@ -6,18 +6,10 @@ export const createRoomMutation = () => {
CreateRoom(userId: $userId) {
id
roomId
}
}
`
}
export const roomQuery = () => {
return gql`
query {
Room {
id
roomId
roomName
lastMessageAt
unreadCount
#avatar
users {
_id
id
@ -30,3 +22,46 @@ export const roomQuery = () => {
}
`
}
export const roomQuery = () => {
return gql`
query Room($first: Int, $offset: Int, $id: ID) {
Room(first: $first, offset: $offset, id: $id, orderBy: lastMessageAt_desc) {
id
roomId
roomName
avatar
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,60 @@
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,
},
Subscription: {
chatMessageAdded: messageProperties,
},
}

View File

@ -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)

View File

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

View File

@ -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 subscriptions', async () => {
await expect(
mutate({
mutation: createMessageMutation(),
@ -122,12 +132,92 @@ 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',
})
expect(pubsubSpy).toBeCalledWith('CHAT_MESSAGE_ADDED', {
chatMessageAdded: expect.objectContaining({
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,
}),
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,
}),
}),
],
},
})
})
})
})

View File

@ -1,7 +1,36 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import Resolver from './helpers/Resolver'
import { getUnreadRoomsCount } from './rooms'
import { pubsub, ROOM_COUNT_UPDATED, CHAT_MESSAGE_ADDED } from '../../server'
import { withFilter } from 'graphql-subscriptions'
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 {
Subscription: {
chatMessageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(CHAT_MESSAGE_ADDED),
(payload, variables) => {
return payload.userId === variables.userId
},
),
},
},
Query: {
Message: async (object, params, context, resolveInfo) => {
const { roomId } = params
@ -20,33 +49,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 +72,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 +86,45 @@ export default {
distributed: false,
seen: false
})-[:INSIDE]->(room)
RETURN message { .* }
SET room.lastMessageAt = toString(datetime())
RETURN message {
.*,
indexId: toString(message.indexId),
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
void pubsub.publish(ROOM_COUNT_UPDATED, {
roomCountUpdated,
userId: message.recipientId,
})
void pubsub.publish(CHAT_MESSAGE_ADDED, {
chatMessageAdded: message,
userId: message.recipientId,
})
}
return message
} catch (error) {
throw new Error(error)

View File

@ -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,319 @@ 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 () => {
await expect(
query({ query: roomQuery(), variables: { first: 3, offset: 0 } }),
).resolves.toMatchObject({
errors: undefined,
data: {
Room: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
roomId: expect.any(String),
roomName: 'Third Chatting User',
lastMessageAt: null,
unreadCount: 0,
lastMessage: null,
users: expect.arrayContaining([
expect.objectContaining({
_id: 'chatting-user',
id: 'chatting-user',
name: 'Chatting User',
avatar: {
url: expect.any(String),
},
}),
expect.objectContaining({
_id: 'third-chatting-user',
id: 'third-chatting-user',
name: 'Third Chatting User',
avatar: {
url: expect.any(String),
},
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
roomId: expect.any(String),
roomName: 'Second Chatting User',
lastMessageAt: null,
unreadCount: 0,
lastMessage: null,
users: expect.arrayContaining([
expect.objectContaining({
_id: 'chatting-user',
id: 'chatting-user',
name: 'Chatting User',
avatar: {
url: expect.any(String),
},
}),
expect.objectContaining({
_id: 'second-chatting-user',
id: 'second-chatting-user',
name: 'Second Chatting User',
avatar: {
url: expect.any(String),
},
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
roomId: expect.any(String),
roomName: 'Other Chatting User',
lastMessageAt: expect.any(String),
unreadCount: 0,
lastMessage: {
_id: expect.any(String),
id: expect.any(String),
content: '2nd 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,
},
users: expect.arrayContaining([
expect.objectContaining({
_id: 'chatting-user',
id: 'chatting-user',
name: 'Chatting User',
avatar: {
url: expect.any(String),
},
}),
expect.objectContaining({
_id: 'other-chatting-user',
id: 'other-chatting-user',
name: 'Other Chatting User',
avatar: {
url: expect.any(String),
},
}),
]),
}),
]),
},
})
await expect(
query({ query: roomQuery(), variables: { first: 3, offset: 3 } }),
).resolves.toMatchObject({
errors: undefined,
data: {
Room: [
expect.objectContaining({
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),
},
},
]),
}),
],
},
})
})
})
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: result.data.Room[0].roomName,
users: expect.any(Array),
},
],
},
})
})
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

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

View File

@ -3,8 +3,7 @@
# }
enum _MessageOrdering {
createdAt_asc
createdAt_desc
indexId_desc
}
type Message {
@ -45,3 +44,7 @@ type Query {
orderBy: [_MessageOrdering]
): [Message]
}
type Subscription {
chatMessageAdded(userId: ID!): Message
}

View File

@ -5,6 +5,11 @@
# users_some: _UserFilter
# }
# TODO change this to last message date
enum _RoomOrdering {
lastMessageAt_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
}

View File

@ -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 = {

View File

@ -27,4 +27,8 @@
chatMessageBgOthers: $chat-message-bg-others;
chatNewMessageColor: $chat-new-message-color;
chatMessageTimestamp: $chat-message-timestamp;
chatMessageCheckmarkSeen: $chat-message-checkmark-seen;
chatMessageCheckmark: $chat-message-checkmark;
}

View File

@ -417,3 +417,6 @@ $chat-message-color: $text-color-base;
$chat-message-bg-others: $color-neutral-80;
$chat-sidemenu-bg: $color-secondary-active;
$chat-new-message-color: $color-secondary-active;
$chat-message-timestamp: $text-color-soft;
$chat-message-checkmark-seen: $text-color-secondary;
$chat-message-checkmark: $text-color-soft;

View File

@ -4,7 +4,7 @@
<vue-advanced-chat
:theme="theme"
:current-user-id="currentUser.id"
:room-id="null"
:room-id="!singleRoom ? roomId : null"
:template-actions="JSON.stringify(templatesText)"
:menu-actions="JSON.stringify(menuActions)"
:text-messages="JSON.stringify(textMessages)"
@ -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,18 +60,28 @@
</template>
<script>
import { roomQuery, createRoom } from '~/graphql/Rooms'
import { messageQuery, createMessageMutation } from '~/graphql/Messages'
import { roomQuery, createRoom, unreadRoomsQuery } from '~/graphql/Rooms'
import {
messageQuery,
createMessageMutation,
chatMessageAdded,
markMessagesAsSeen,
} from '~/graphql/Messages'
import chatStyle from '~/constants/chat.js'
import { mapGetters } from 'vuex'
import { mapGetters, mapMutations } from 'vuex'
export default {
name: 'Chat',
props: {
theme: {
type: String,
default: 'light',
},
singleRoomId: {
singleRoom: {
type: Boolean,
default: false,
},
roomId: {
type: String,
default: null,
},
@ -82,31 +94,32 @@ export default {
name: 'dummyItem',
title: 'Just a dummy item',
},
/* {
name: 'inviteUser',
title: 'Invite User',
},
{
name: 'removeUser',
title: 'Remove User',
},
{
name: 'deleteRoom',
title: 'Delete Room',
},
*/
/*
{
name: 'inviteUser',
title: 'Invite User',
},
{
name: 'removeUser',
title: 'Remove User',
},
{
name: 'deleteRoom',
title: 'Delete Room',
},
*/
],
messageActions: [
/*
{
name: 'addMessageToFavorite',
title: 'Add To Favorite',
},
{
name: 'shareMessage',
title: 'Share Message',
},
*/
{
name: 'addMessageToFavorite',
title: 'Add To Favorite',
},
{
name: 'shareMessage',
title: 'Share Message',
},
*/
],
templatesText: [
{
@ -118,7 +131,78 @@ export default {
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,
selectedRoom: this.roomId,
loadingRooms: true,
messagesLoaded: false,
messagePage: 0,
messagePageSize: 20,
messages: [],
}
},
mounted() {
if (this.singleRoom) {
this.$apollo
.mutate({
mutation: createRoom(),
variables: {
userId: this.roomId,
},
})
.then(({ data: { CreateRoom } }) => {
this.fetchRooms({ room: CreateRoom })
})
.catch((error) => {
this.$toast.error(error)
})
.finally(() => {
// this.loading = false
})
} else {
this.fetchRooms()
}
// Subscriptions
const observer = this.$apollo.subscribe({
query: chatMessageAdded(),
variables: {
userId: this.currentUser.id,
},
})
observer.subscribe({
next: this.chatMessageAdded,
error(error) {
this.$toast.error(error)
},
})
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
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'),
@ -131,67 +215,57 @@ export default {
LAST_SEEN: this.$t('chat.lastSeen'),
IS_TYPING: this.$t('chat.isTyping'),
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: {
...mapMutations({
commitUnreadRoomCount: 'chat/UPDATE_ROOM_COUNT',
}),
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 = {} }) {
if (this.selectedRoom !== room.id) {
if (this.selectedRoom?.id !== room.id) {
this.messages = []
this.messagePage = 0
this.selectedRoom = room.id
this.selectedRoom = room
}
this.messagesLoaded = options.refetch ? this.messagesLoaded : false
const offset = (options.refetch ? 0 : this.messagePage) * this.messagePageSize
@ -208,8 +282,31 @@ export default {
fetchPolicy: 'no-cache',
})
const newMsgIds = Message.filter((m) => m.seen === false).map((m) => m.id)
if (newMsgIds.length) {
this.$apollo
.mutate({
mutation: markMessagesAsSeen(),
variables: {
messageIds: newMsgIds,
},
})
.then(() => {
this.$apollo
.query({
query: unreadRoomsQuery(),
fetchPolicy: 'network-only',
})
.then(({ data: { UnreadRooms } }) => {
this.commitUnreadRoomCount(UnreadRooms)
})
})
}
const msgs = []
;[...this.messages, ...Message].forEach((m) => {
if (m.senderId !== this.currentUser.id) m.seen = true
m.date = new Date(m.date).toDateString()
msgs[m.indexId] = m
})
this.messages = msgs.filter(Boolean)
@ -224,14 +321,25 @@ export default {
}
},
refetchMessage(roomId) {
this.fetchMessages({
room: this.rooms.find((r) => r.roomId === roomId),
options: { refetch: true },
})
async chatMessageAdded({ data }) {
if (data.chatMessageAdded.room.id === this.selectedRoom?.id) {
this.fetchMessages({ room: this.selectedRoom, options: { refetch: true } })
} else {
// TODO this might be optimized selectively (first page vs rest)
this.rooms = []
this.roomPage = 0
this.roomsLoaded = false
this.fetchRooms()
}
},
async sendMessage(message) {
// check for usersTag and change userid to username
message.usersTag.forEach((userTag) => {
const needle = `<usertag>${userTag.id}</usertag>`
const replacement = `<usertag>@${userTag.name.replaceAll(' ', '-').toLowerCase()}</usertag>`
message.content = message.content.replaceAll(needle, replacement)
})
try {
await this.$apollo.mutate({
mutation: createMessageMutation(),
@ -243,7 +351,10 @@ export default {
} catch (error) {
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) {
@ -251,45 +362,6 @@ export default {
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>
<style lang="scss">

View File

@ -8,18 +8,52 @@
placement: 'bottom-start',
}"
>
<counter-icon icon="chat-bubble" :count="1" danger />
<counter-icon icon="chat-bubble" :count="unreadRoomCount" danger />
</base-button>
</nuxt-link>
</template>
<script>
import { mapGetters, mapMutations } from 'vuex'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import { unreadRoomsQuery, roomCountUpdated } from '~/graphql/Rooms'
export default {
name: 'ChatNotificationMenu',
components: {
CounterIcon,
},
computed: {
...mapGetters({
user: 'auth/user',
unreadRoomCount: 'chat/unreadRoomCount',
}),
},
methods: {
...mapMutations({
commitUnreadRoomCount: 'chat/UPDATE_ROOM_COUNT',
}),
},
apollo: {
UnreadRooms: {
query() {
return unreadRoomsQuery()
},
update({ UnreadRooms }) {
this.commitUnreadRoomCount(UnreadRooms)
},
subscribeToMore: {
document: roomCountUpdated(),
variables() {
return {
userId: this.user.id,
}
},
updateQuery: (previousResult, { subscriptionData }) => {
return { UnreadRooms: subscriptionData.data.roomCountUpdated }
},
},
},
},
}
</script>

View File

@ -146,6 +146,7 @@ export default {
const {
data: { notificationAdded: newNotification },
} = subscriptionData
return {
notifications: unionBy(
[newNotification],

View File

@ -65,7 +65,7 @@ const STYLE = {
backgroundSelected: '#c2dcf2',
colorDeleted: '#757e85',
colorUsername: '#9ca6af',
colorTimestamp: '#828c94',
colorTimestamp: styleData.chatMessageTimestamp,
backgroundDate: '#e5effa',
colorDate: '#505a62',
backgroundSystem: '#e5effa',
@ -134,8 +134,8 @@ const STYLE = {
emojiReaction: 'rgba(0, 0, 0, 0.3)',
document: styleData.colorPrimary,
pencil: '#9e9e9e',
checkmark: '#9e9e9e',
checkmarkSeen: '#0696c7',
checkmark: styleData.chatMessageCheckmark,
checkmarkSeen: styleData.chatMessageCheckmarkSeen,
eye: '#fff',
dropdownMessage: '#fff',
dropdownMessageBackground: 'rgba(0, 0, 0, 0.25)',

View File

@ -1,22 +1,5 @@
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) {
_id
id
indexId
senderId
content
author {
id
}
}
}
`
}
export const createMessageMutation = () => {
return gql`
mutation ($roomId: ID!, $content: String!) {
@ -27,3 +10,60 @@ export const createMessageMutation = () => {
}
`
}
export const messageQuery = () => {
return gql`
query ($roomId: ID!, $first: Int, $offset: Int) {
Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) {
_id
id
indexId
content
senderId
author {
id
}
username
avatar
date
saved
distributed
seen
}
}
`
}
export const chatMessageAdded = () => {
return gql`
subscription chatMessageAdded($userId: ID!) {
chatMessageAdded(userId: $userId) {
_id
id
indexId
content
senderId
author {
id
}
username
avatar
date
room {
id
}
saved
distributed
seen
}
}
`
}
export const markMessagesAsSeen = () => {
return gql`
mutation ($messageIds: [String!]) {
MarkMessagesAsSeen(messageIds: $messageIds)
}
`
}

View File

@ -1,12 +1,35 @@
import gql from 'graphql-tag'
export const createRoom = () => gql`
mutation ($userId: ID!) {
CreateRoom(userId: $userId) {
id
roomId
}
}
`
export const roomQuery = () => gql`
query Room($first: Int, $offset: Int) {
Room(first: $first, offset: $offset) {
query Room($first: Int, $offset: Int, $id: ID) {
Room(first: $first, offset: $offset, id: $id, orderBy: lastMessageAt_desc) {
id
roomId
roomName
avatar
lastMessageAt
unreadCount
lastMessage {
_id
id
content
senderId
username
avatar
date
saved
distributed
seen
}
users {
_id
id
@ -19,11 +42,18 @@ export const roomQuery = () => gql`
}
`
export const createRoom = () => gql`
mutation ($userId: ID!) {
CreateRoom(userId: $userId) {
id
roomId
export const unreadRoomsQuery = () => {
return gql`
query {
UnreadRooms
}
}
`
`
}
export const roomCountUpdated = () => {
return gql`
subscription roomCountUpdated($userId: ID!) {
roomCountUpdated(userId: $userId)
}
`
}

View File

@ -13,33 +13,40 @@
<client-only>
<modal />
</client-only>
<div v-if="$store.getters['chat/showChat'].showChat" class="chat-modul">
<chat-module
v-on:close-single-room="closeSingleRoom"
:singleRoomId="$store.getters['chat/showChat'].roomID"
/>
<div v-if="getShowChat.showChat" class="chat-modul">
<chat singleRoom :roomId="getShowChat.roomID" @close-single-room="closeSingleRoom" />
</div>
</div>
</template>
<script>
import { mapGetters, mapMutations } from 'vuex'
import seo from '~/mixins/seo'
import mobile from '~/mixins/mobile'
import HeaderMenu from '~/components/HeaderMenu/HeaderMenu'
import Modal from '~/components/Modal'
import PageFooter from '~/components/PageFooter/PageFooter'
import ChatModule from '~/components/Chat/Chat.vue'
import Chat from '~/components/Chat/Chat.vue'
export default {
components: {
HeaderMenu,
Modal,
PageFooter,
ChatModule,
Chat,
},
mixins: [seo, mobile()],
computed: {
...mapGetters({
getShowChat: 'chat/showChat',
}),
},
methods: {
...mapMutations({
showChat: 'chat/SET_OPEN_CHAT',
}),
closeSingleRoom() {
this.$store.commit('chat/SET_OPEN_CHAT', { showChat: false, roomID: null })
this.showChat({ showChat: false, roomID: null })
},
},
beforeCreate() {

View File

@ -86,9 +86,12 @@
"messageDeleted": "Diese Nachricht wuerde gelöscht",
"messagesEmpty": "Keine Nachrichten",
"newMessages": "Neue Nachrichten",
"page": {
"headline": "Chat"
},
"roomEmpty": "Keinen Raum selektiert",
"roomsEmpty": "Keine Räume",
"search": "Suche",
"search": "Chat-Räume filtern",
"typeMessage": "Nachricht schreiben",
"userProfileButton": {
"label": "Chat",

View File

@ -86,9 +86,12 @@
"messageDeleted": "This message was deleted",
"messagesEmpty": "No messages",
"newMessages": "New Messages",
"page": {
"headline": "Chat"
},
"roomEmpty": "No room selected",
"roomsEmpty": "No rooms",
"search": "Search",
"search": "Filter chat rooms",
"typeMessage": "Type message",
"userProfileButton": {
"label": "Chat",

View File

@ -75,6 +75,9 @@
}
}
},
"chat": {
"search": "Filtrar salas de chat"
},
"code-of-conduct": {
"subheader": "para la red social de {ORGANIZATION_NAME}"
},

View File

@ -75,6 +75,9 @@
}
}
},
"chat": {
"search": "Filtrer les salons de chat"
},
"code-of-conduct": {
"subheader": "pour le réseau social de {ORGANIZATION_NAME}"
},

View File

@ -1,11 +1,28 @@
<template>
<chat />
<div>
<ds-heading tag="h1">{{ $t('chat.page.headline') }}</ds-heading>
<chat :roomId="getShowChat.showChat ? getShowChat.roomID : null" />
</div>
</template>
<script>
import { mapGetters, mapMutations } from 'vuex'
import Chat from '../components/Chat/Chat.vue'
export default {
components: { Chat },
mounted() {
this.showChat({ showChat: false, roomID: null })
},
computed: {
...mapGetters({
getShowChat: 'chat/showChat',
}),
},
methods: {
...mapMutations({
showChat: 'chat/SET_OPEN_CHAT',
}),
},
}
</script>

View File

@ -85,7 +85,7 @@
content: $t('chat.userProfileButton.tooltip', { name: userName }),
placement: 'bottom-start',
}"
@click="showChat({ showChat: true, roomID: user.id })"
@click="showOrChangeChat(user.id)"
>
{{ $t('chat.userProfileButton.label') }}
</base-button>
@ -182,7 +182,7 @@
<script>
import uniqBy from 'lodash/uniqBy'
import { mapMutations } from 'vuex'
import { mapGetters, mapMutations } from 'vuex'
import postListActions from '~/mixins/postListActions'
import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import HcFollowButton from '~/components/Button/FollowButton'
@ -254,6 +254,9 @@ export default {
}
},
computed: {
...mapGetters({
getShowChat: 'chat/showChat',
}),
myProfile() {
return this.$route.params.id === this.$store.getters['auth/user'].id
},
@ -403,6 +406,12 @@ export default {
if (type === 'following') this.followingCount = count
if (type === 'followedBy') this.followedByCount = count
},
async showOrChangeChat(roomID) {
if (this.getShowChat.showChat) {
await this.showChat({ showChat: false, roomID: null })
}
await this.showChat({ showChat: true, roomID })
},
},
apollo: {
profilePagePosts: {

View File

@ -2,6 +2,7 @@ export const state = () => {
return {
showChat: false,
roomID: null,
unreadRoomCount: 0,
}
}
@ -10,6 +11,9 @@ export const mutations = {
state.showChat = ctx.showChat || false
state.roomID = ctx.roomID || null
},
UPDATE_ROOM_COUNT(state, count) {
state.unreadRoomCount = count
},
}
export const getters = {
@ -19,4 +23,7 @@ export const getters = {
roomID(state) {
return state
},
unreadRoomCount(state) {
return state.unreadRoomCount
},
}