mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +00:00
Merge branch 'master' into 6443-refactor-create-post-page
This commit is contained in:
commit
a8f32cad75
2
.github/file-filters.yml
vendored
2
.github/file-filters.yml
vendored
@ -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/**/*'
|
||||
|
||||
42
.github/workflows/cleanup-cache-at-pr-closing.yml
vendored
Normal file
42
.github/workflows/cleanup-cache-at-pr-closing.yml
vendored
Normal 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 }}
|
||||
74
.github/workflows/test-backend.yml
vendored
74
.github/workflows/test-backend.yml
vendored
@ -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
|
||||
|
||||
102
.github/workflows/test-e2e.yml
vendored
102
.github/workflows/test-e2e.yml
vendored
@ -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
|
||||
44
.github/workflows/test-webapp.yml
vendored
44
.github/workflows/test-webapp.yml
vendored
@ -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
@ -6,6 +6,10 @@ export const createMessageMutation = () => {
|
||||
CreateMessage(roomId: $roomId, content: $content) {
|
||||
id
|
||||
content
|
||||
senderId
|
||||
username
|
||||
avatar
|
||||
date
|
||||
saved
|
||||
distributed
|
||||
seen
|
||||
@ -16,12 +20,16 @@ export const createMessageMutation = () => {
|
||||
|
||||
export const messageQuery = () => {
|
||||
return gql`
|
||||
query ($roomId: ID!) {
|
||||
Message(roomId: $roomId) {
|
||||
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
|
||||
|
||||
@ -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
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@ -9,10 +9,9 @@ function walkRecursive(data, fields, fieldName, callback, _key?) {
|
||||
if (!Array.isArray(fields)) {
|
||||
throw new Error('please provide an fields array for the walkRecursive helper')
|
||||
}
|
||||
if (data && typeof data === 'string' && fields.includes(_key)) {
|
||||
// well we found what we searched for, lets replace the value with our callback result
|
||||
const key = _key.split('!')
|
||||
if (key.length === 1 || key[1] !== fieldName) data = callback(data, key[0])
|
||||
const fieldDef = fields.find((f) => f.field === _key)
|
||||
if (data && typeof data === 'string' && fieldDef) {
|
||||
if (!fieldDef.excludes?.includes(fieldName)) data = callback(data, _key)
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// go into the rabbit hole and dig through that array
|
||||
data.forEach((res, index) => {
|
||||
|
||||
60
backend/src/middleware/chatMiddleware.ts
Normal file
60
backend/src/middleware/chatMiddleware.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
@ -30,6 +30,7 @@ const standardSanitizeHtmlOptions = {
|
||||
'strike',
|
||||
'span',
|
||||
'blockquote',
|
||||
'usertag',
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'class', 'target', 'data-*', 'contenteditable'],
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -408,6 +408,7 @@ export default shield(
|
||||
getInviteCode: isAuthenticated, // and inviteRegistration
|
||||
Room: isAuthenticated,
|
||||
Message: isAuthenticated,
|
||||
UnreadRooms: isAuthenticated,
|
||||
},
|
||||
Mutation: {
|
||||
'*': deny,
|
||||
|
||||
@ -3,11 +3,11 @@ import { cleanHtml } from '../middleware/helpers/cleanHtml'
|
||||
|
||||
// exclamation mark separetes field names, that should not be sanitized
|
||||
const fields = [
|
||||
'content',
|
||||
'contentExcerpt',
|
||||
'reasonDescription',
|
||||
'description!embed',
|
||||
'descriptionExcerpt',
|
||||
{ field: 'content', excludes: ['CreateMessage', 'Message'] },
|
||||
{ field: 'contentExcerpt' },
|
||||
{ field: 'reasonDescription' },
|
||||
{ field: 'description', excludes: ['embed'] },
|
||||
{ field: 'descriptionExcerpt' },
|
||||
]
|
||||
|
||||
export default {
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -215,6 +305,7 @@ describe('Message', () => {
|
||||
{
|
||||
id: expect.any(String),
|
||||
_id: result.data.Message[0].id,
|
||||
indexId: 0,
|
||||
content: 'Some nice message to other chatting user',
|
||||
senderId: 'chatting-user',
|
||||
username: 'Chatting User',
|
||||
@ -259,9 +350,10 @@ describe('Message', () => {
|
||||
).resolves.toMatchObject({
|
||||
errors: undefined,
|
||||
data: {
|
||||
Message: expect.arrayContaining([
|
||||
Message: [
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
indexId: 0,
|
||||
content: 'Some nice message to other chatting user',
|
||||
senderId: 'chatting-user',
|
||||
username: 'Chatting User',
|
||||
@ -273,6 +365,7 @@ describe('Message', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
indexId: 1,
|
||||
content: 'A nice response message to chatting user',
|
||||
senderId: 'other-chatting-user',
|
||||
username: 'Other Chatting User',
|
||||
@ -284,6 +377,7 @@ describe('Message', () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
indexId: 2,
|
||||
content: 'And another nice message to other chatting user',
|
||||
senderId: 'chatting-user',
|
||||
username: 'Chatting User',
|
||||
@ -293,7 +387,70 @@ describe('Message', () => {
|
||||
distributed: false,
|
||||
seen: false,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the messages paginated', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: messageQuery(),
|
||||
variables: {
|
||||
roomId,
|
||||
first: 2,
|
||||
offset: 0,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: undefined,
|
||||
data: {
|
||||
Message: [
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
indexId: 1,
|
||||
content: 'A nice response message to chatting user',
|
||||
senderId: 'other-chatting-user',
|
||||
username: 'Other Chatting User',
|
||||
avatar: expect.any(String),
|
||||
date: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
indexId: 2,
|
||||
content: 'And another nice message to other chatting user',
|
||||
senderId: 'chatting-user',
|
||||
username: 'Chatting User',
|
||||
avatar: expect.any(String),
|
||||
date: expect.any(String),
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
await expect(
|
||||
query({
|
||||
query: messageQuery(),
|
||||
variables: {
|
||||
roomId,
|
||||
first: 2,
|
||||
offset: 2,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: undefined,
|
||||
data: {
|
||||
Message: [
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
indexId: 0,
|
||||
content: 'Some nice message to other chatting user',
|
||||
senderId: 'chatting-user',
|
||||
username: 'Chatting User',
|
||||
avatar: expect.any(String),
|
||||
date: expect.any(String),
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
@ -13,41 +42,24 @@ export default {
|
||||
id: context.user.id,
|
||||
},
|
||||
}
|
||||
|
||||
const resolved = await neo4jgraphql(object, params, context, resolveInfo)
|
||||
|
||||
if (resolved) {
|
||||
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
|
||||
return resolved.reverse()
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
@ -60,28 +72,59 @@ 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)
|
||||
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(),
|
||||
content: $content,
|
||||
indexId: CASE WHEN maxIndex IS NOT NULL THEN maxIndex + 1 ELSE 0 END,
|
||||
content: LEFT($content,2000),
|
||||
saved: true,
|
||||
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)
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)',
|
||||
},
|
||||
|
||||
@ -2,8 +2,13 @@
|
||||
# room: _RoomFilter
|
||||
# }
|
||||
|
||||
enum _MessageOrdering {
|
||||
indexId_desc
|
||||
}
|
||||
|
||||
type Message {
|
||||
id: ID!
|
||||
indexId: Int!
|
||||
createdAt: String
|
||||
updatedAt: String
|
||||
|
||||
@ -32,5 +37,14 @@ type Mutation {
|
||||
}
|
||||
|
||||
type Query {
|
||||
Message(roomId: ID!): [Message]
|
||||
Message(
|
||||
roomId: ID!,
|
||||
first: Int
|
||||
offset: Int
|
||||
orderBy: [_MessageOrdering]
|
||||
): [Message]
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
chatMessageAdded(userId: ID!): Message
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -4,23 +4,27 @@
|
||||
<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)"
|
||||
:message-actions="messageActions"
|
||||
:messages="JSON.stringify(messages)"
|
||||
: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"
|
||||
@show-demo-options="showDemoOptions = $event"
|
||||
>
|
||||
<div slot="menu-icon" @click.prevent.stop="$emit('close-single-room', true)">
|
||||
@ -31,7 +35,7 @@
|
||||
|
||||
<div slot="room-header-avatar">
|
||||
<div
|
||||
v-if="selectedRoom && selectedRoom.avatar && selectedRoom.avatar !== 'default-avatar'"
|
||||
v-if="selectedRoom && selectedRoom.avatar"
|
||||
class="vac-avatar"
|
||||
:style="{ 'background-image': `url('${selectedRoom.avatar}')` }"
|
||||
/>
|
||||
@ -42,7 +46,7 @@
|
||||
|
||||
<div v-for="room in rooms" :slot="'room-list-avatar_' + room.id" :key="room.id">
|
||||
<div
|
||||
v-if="room.avatar && room.avatar !== 'default-avatar'"
|
||||
v-if="room.avatar"
|
||||
class="vac-avatar"
|
||||
:style="{ 'background-image': `url('${room.avatar}')` }"
|
||||
/>
|
||||
@ -56,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,
|
||||
},
|
||||
@ -80,28 +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: [
|
||||
{
|
||||
@ -113,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'),
|
||||
@ -126,99 +215,165 @@ 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,
|
||||
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: {
|
||||
fetchMessages({ room, options = {} }) {
|
||||
this.messagesLoaded = false
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const {
|
||||
data: { Message },
|
||||
} = await this.$apollo.query({
|
||||
query: messageQuery(),
|
||||
variables: {
|
||||
roomId: room.id,
|
||||
},
|
||||
fetchPolicy: 'no-cache',
|
||||
})
|
||||
this.messages = Message
|
||||
} catch (error) {
|
||||
this.messages = []
|
||||
this.$toast.error(error.message)
|
||||
}
|
||||
this.messagesLoaded = true
|
||||
...mapMutations({
|
||||
commitUnreadRoomCount: 'chat/UPDATE_ROOM_COUNT',
|
||||
}),
|
||||
async fetchRooms({ room, options = {} } = {}) {
|
||||
this.roomsLoaded = options.refetch ? this.roomsLoaded : false
|
||||
const offset = (options.refetch ? 0 : 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',
|
||||
})
|
||||
|
||||
this.selectedRoom = room
|
||||
})
|
||||
const rms = []
|
||||
const rmsIds = []
|
||||
;[...Room, ...this.rooms].forEach((r) => {
|
||||
if (!rmsIds.find((v) => v === r.id)) {
|
||||
rms.push({
|
||||
...r,
|
||||
index: r.lastMessage?.date,
|
||||
lastMessage: {
|
||||
...r.lastMessage,
|
||||
content: r.lastMessage?.content.trim().substring(0, 30),
|
||||
},
|
||||
users: r.users.map((u) => {
|
||||
return { ...u, username: u.name, avatar: u.avatar?.url }
|
||||
}),
|
||||
})
|
||||
rmsIds.push(r.id)
|
||||
}
|
||||
})
|
||||
this.rooms = rms
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
refetchMessage(roomId) {
|
||||
this.fetchMessages({ room: this.rooms.find((r) => r.roomId === roomId) })
|
||||
async fetchMessages({ room, options = {} }) {
|
||||
if (this.selectedRoom?.id !== room.id) {
|
||||
this.messages = []
|
||||
this.messagePage = 0
|
||||
this.selectedRoom = room
|
||||
}
|
||||
this.messagesLoaded = options.refetch ? this.messagesLoaded : false
|
||||
const offset = (options.refetch ? 0 : this.messagePage) * this.messagePageSize
|
||||
try {
|
||||
const {
|
||||
data: { Message },
|
||||
} = await this.$apollo.query({
|
||||
query: messageQuery(),
|
||||
variables: {
|
||||
roomId: room.id,
|
||||
first: this.messagePageSize,
|
||||
offset,
|
||||
},
|
||||
fetchPolicy: 'no-cache',
|
||||
})
|
||||
|
||||
const newMsgIds = Message.filter(
|
||||
(m) => m.seen === false && m.senderId !== this.currentUser.id,
|
||||
).map((m) => m.id)
|
||||
if (newMsgIds.length) {
|
||||
const roomIndex = this.rooms.findIndex((r) => r.id === room.id)
|
||||
const changedRoom = { ...this.rooms[roomIndex] }
|
||||
changedRoom.unreadCount = changedRoom.unreadCount - newMsgIds.length
|
||||
this.rooms[roomIndex] = changedRoom
|
||||
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)
|
||||
|
||||
if (Message.length < this.messagePageSize) {
|
||||
this.messagesLoaded = true
|
||||
}
|
||||
this.messagePage += 1
|
||||
} catch (error) {
|
||||
this.messages = []
|
||||
this.$toast.error(error.message)
|
||||
}
|
||||
},
|
||||
|
||||
async chatMessageAdded({ data }) {
|
||||
const roomIndex = this.rooms.findIndex((r) => r.id === data.chatMessageAdded.room.id)
|
||||
const changedRoom = { ...this.rooms[roomIndex] }
|
||||
changedRoom.lastMessage = data.chatMessageAdded
|
||||
changedRoom.lastMessage.content = changedRoom.lastMessage.content.trim().substring(0, 30)
|
||||
changedRoom.lastMessageAt = data.chatMessageAdded.date
|
||||
changedRoom.unreadCount++
|
||||
this.rooms[roomIndex] = changedRoom
|
||||
if (data.chatMessageAdded.room.id === this.selectedRoom?.id) {
|
||||
this.fetchMessages({ room: this.selectedRoom, options: { refetch: true } })
|
||||
} else {
|
||||
this.fetchRooms({ options: { refetch: true } })
|
||||
}
|
||||
},
|
||||
|
||||
async sendMessage(message) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
const {
|
||||
data: { CreateMessage: createdMessage },
|
||||
} = await this.$apollo.mutate({
|
||||
mutation: createMessageMutation(),
|
||||
variables: {
|
||||
roomId: message.roomId,
|
||||
content: message.content,
|
||||
},
|
||||
})
|
||||
const roomIndex = this.rooms.findIndex((r) => r.id === message.roomId)
|
||||
const changedRoom = { ...this.rooms[roomIndex] }
|
||||
changedRoom.lastMessage = createdMessage
|
||||
changedRoom.lastMessage.content = changedRoom.lastMessage.content.trim().substring(0, 30)
|
||||
this.rooms[roomIndex] = changedRoom
|
||||
} 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) {
|
||||
@ -226,39 +381,6 @@ export default {
|
||||
return fullname.match(/\b\w/g).join('').substring(0, 3).toUpperCase()
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
Rooms: {
|
||||
query() {
|
||||
return roomQuery()
|
||||
},
|
||||
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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -146,6 +146,7 @@ export default {
|
||||
const {
|
||||
data: { notificationAdded: newNotification },
|
||||
} = subscriptionData
|
||||
|
||||
return {
|
||||
notifications: unionBy(
|
||||
[newNotification],
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -1,28 +1,87 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const messageQuery = () => {
|
||||
return gql`
|
||||
query ($roomId: ID!) {
|
||||
Message(roomId: $roomId) {
|
||||
_id
|
||||
id
|
||||
senderId
|
||||
content
|
||||
author {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const createMessageMutation = () => {
|
||||
return gql`
|
||||
mutation ($roomId: ID!, $content: String!) {
|
||||
CreateMessage(roomId: $roomId, content: $content) {
|
||||
#_id
|
||||
id
|
||||
indexId
|
||||
content
|
||||
senderId
|
||||
author {
|
||||
id
|
||||
}
|
||||
username
|
||||
avatar
|
||||
date
|
||||
room {
|
||||
id
|
||||
}
|
||||
saved
|
||||
distributed
|
||||
seen
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
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
|
||||
room {
|
||||
id
|
||||
}
|
||||
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)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
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)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -75,6 +75,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"search": "Filtrar salas de chat"
|
||||
},
|
||||
"code-of-conduct": {
|
||||
"subheader": "para la red social de {ORGANIZATION_NAME}"
|
||||
},
|
||||
|
||||
@ -75,6 +75,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"search": "Filtrer les salons de chat"
|
||||
},
|
||||
"code-of-conduct": {
|
||||
"subheader": "pour le réseau social de {ORGANIZATION_NAME}"
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
},
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user