Merge branch 'master' of github.com:Ocelot-Social-Community/Ocelot-Social into 6593-@all-mention-for-network-admins

This commit is contained in:
Wolfgang Huß 2023-07-20 16:53:36 +02:00
commit cf8c224062
54 changed files with 1545 additions and 1123 deletions

View File

@ -1,4 +1,5 @@
backend: &backend backend: &backend
- '.github/workflows/test-backend.yml'
- 'backend/**/*' - 'backend/**/*'
- 'neo4j/**/*' - 'neo4j/**/*'
@ -6,4 +7,5 @@ docker: &docker
- 'docker-compose.*' - 'docker-compose.*'
webapp: &webapp webapp: &webapp
- '.github/workflows/test-webapp.yml'
- 'webapp/**/*' - '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 name: ocelot.social backend test CI
on: [push] on: push
jobs: jobs:
files-changed: files-changed:
@ -13,7 +13,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v3.3.0 - 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 uses: dorny/paths-filter@v2.11.1
id: changes id: changes
with: with:
@ -34,12 +34,13 @@ jobs:
run: | run: |
docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/ docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/
docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/neo4j.tar docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/neo4j.tar
- name: Upload Artifact - name: Cache docker images
uses: actions/upload-artifact@v3 id: cache-neo4j
uses: actions/cache/save@v3.3.1
with: with:
name: docker-neo4j-image
path: /tmp/neo4j.tar path: /tmp/neo4j.tar
key: ${{ github.run_id }}-backend-neo4j-cache
build_test_backend: build_test_backend:
name: Docker Build Test - Backend name: Docker Build Test - Backend
@ -54,12 +55,13 @@ jobs:
run: | run: |
docker build --target test -t "ocelotsocialnetwork/backend:test" backend/ docker build --target test -t "ocelotsocialnetwork/backend:test" backend/
docker save "ocelotsocialnetwork/backend:test" > /tmp/backend.tar docker save "ocelotsocialnetwork/backend:test" > /tmp/backend.tar
- name: Upload Artifact - name: Cache docker images
uses: actions/upload-artifact@v3 id: cache-backend
uses: actions/cache/save@v3.3.1
with: with:
name: docker-backend-test
path: /tmp/backend.tar path: /tmp/backend.tar
key: ${{ github.run_id }}-backend-cache
lint_backend: lint_backend:
name: Lint Backend name: Lint Backend
@ -84,28 +86,29 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Download Docker Image (Neo4J) - name: Restore Neo4J cache
uses: actions/download-artifact@v3 uses: actions/cache/restore@v3.3.1
with: with:
name: docker-neo4j-image path: /tmp/neo4j.tar
path: /tmp key: ${{ github.run_id }}-backend-neo4j-cache
fail-on-cache-miss: true
- name: Load Docker Image - name: Restore Backend cache
run: docker load < /tmp/neo4j.tar uses: actions/cache/restore@v3.3.1
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v3
with: with:
name: docker-backend-test path: /tmp/backend.tar
path: /tmp key: ${{ github.run_id }}-backend-cache
fail-on-cache-miss: true
- name: Load Docker Image - name: Load Docker Images
run: docker load < /tmp/backend.tar run: |
docker load < /tmp/neo4j.tar
docker load < /tmp/backend.tar
- name: backend | copy env files webapp - name: backend | copy env files
run: cp webapp/.env.template webapp/.env run: |
- name: backend | copy env files backend cp webapp/.env.template webapp/.env
run: cp backend/.env.template backend/.env cp backend/.env.template backend/.env
- name: backend | docker-compose - name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend 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 - name: backend | Unit test incl. coverage check
run: docker-compose exec -T backend yarn test 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,12 +1,11 @@
name: ocelot.social end-to-end test CI name: ocelot.social end-to-end test CI
on: push on: push
jobs: jobs:
docker_preparation: docker_preparation:
name: Fullstack test preparation name: Fullstack test preparation
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
pr-number: ${{ steps.pr.outputs.number }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -34,10 +33,6 @@ jobs:
yarn build yarn build
cd .. cd ..
yarn install yarn install
- name: Get pr number
id: pr
uses: 8BitJonny/gh-get-current-pr@2.2.0
- name: Cache docker images - name: Cache docker images
id: cache id: cache
@ -48,7 +43,7 @@ jobs:
/home/runner/.cache/Cypress /home/runner/.cache/Cypress
/home/runner/work/Ocelot-Social/Ocelot-Social /home/runner/work/Ocelot-Social/Ocelot-Social
/tmp/images/ /tmp/images/
key: e2e-preparation-cache-pr${{ steps.pr.outputs.number }} key: ${{ github.run_id }}-e2e-preparation-cache
fullstack_tests: fullstack_tests:
name: Fullstack tests name: Fullstack tests
@ -71,7 +66,7 @@ jobs:
/home/runner/.cache/Cypress /home/runner/.cache/Cypress
/home/runner/work/Ocelot-Social/Ocelot-Social /home/runner/work/Ocelot-Social/Ocelot-Social
/tmp/images/ /tmp/images/
key: e2e-preparation-cache-pr${{ needs.docker_preparation.outputs.pr-number }} key: ${{ github.run_id }}-e2e-preparation-cache
fail-on-cache-miss: true fail-on-cache-miss: true
- name: Boot up test system | docker-compose - name: Boot up test system | docker-compose
@ -104,14 +99,14 @@ jobs:
cleanup: cleanup:
name: Cleanup name: Cleanup
if: always()
needs: [docker_preparation, fullstack_tests] needs: [docker_preparation, fullstack_tests]
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true
steps: steps:
- name: Delete cache - name: Delete cache
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
gh extension install actions/gh-actions-cache gh extension install actions/gh-actions-cache
KEY="e2e-preparation-cache-pr${{ needs.docker_preparation.outputs.pr-number }}" KEY="${{ github.run_id }}-e2e-preparation-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm

View File

@ -1,7 +1,7 @@
name: ocelot.social webapp test CI name: ocelot.social webapp test CI
on: [push] on: push
jobs: jobs:
files-changed: files-changed:
@ -34,7 +34,7 @@ jobs:
run: | run: |
scripts/translations/sort.sh scripts/translations/sort.sh
scripts/translations/missing-keys.sh scripts/translations/missing-keys.sh
build_test_webapp: build_test_webapp:
name: Docker Build Test - Webapp name: Docker Build Test - Webapp
if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp == 'true' if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp == 'true'
@ -44,16 +44,16 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: webapp | Build 'test' image - name: Webapp | Build 'test' image
run: | run: |
docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/ docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/
docker save "ocelotsocialnetwork/webapp:test" > /tmp/webapp.tar docker save "ocelotsocialnetwork/webapp:test" > /tmp/webapp.tar
- name: Upload Artifact - name: Cache docker image
uses: actions/upload-artifact@v3 uses: actions/cache/save@v3.3.1
with: with:
name: docker-webapp-test
path: /tmp/webapp.tar path: /tmp/webapp.tar
key: ${{ github.run_id }}-webapp-cache
lint_webapp: lint_webapp:
name: Lint Webapp name: Lint Webapp
@ -78,20 +78,19 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Download Docker Image (Webapp) - name: Restore webapp cache
uses: actions/download-artifact@v3 uses: actions/cache/restore@v3.3.1
with: with:
name: docker-webapp-test path: /tmp/webapp.tar
path: /tmp key: ${{ github.run_id }}-webapp-cache
- name: Load Docker Image - name: Load Docker Image
run: docker load < /tmp/webapp.tar run: docker load < /tmp/webapp.tar
- name: backend | copy env files webapp - name: Copy env files
run: cp webapp/.env.template webapp/.env run: |
cp webapp/.env.template webapp/.env
- name: backend | copy env files backend cp backend/.env.template backend/.env
run: cp backend/.env.template backend/.env
- name: backend | docker-compose - name: backend | docker-compose
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp 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 - name: webapp | Unit tests incl. coverage check
run: docker-compose exec -T webapp yarn test 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

@ -27,6 +27,9 @@ export const messageQuery = () => {
indexId indexId
content content
senderId senderId
author {
id
}
username username
avatar avatar
date date

View File

@ -9,6 +9,7 @@ export const createRoomMutation = () => {
roomName roomName
lastMessageAt lastMessageAt
unreadCount unreadCount
#avatar
users { users {
_id _id
id id
@ -25,10 +26,11 @@ export const createRoomMutation = () => {
export const roomQuery = () => { export const roomQuery = () => {
return gql` return gql`
query Room($first: Int, $offset: Int, $id: ID) { query Room($first: Int, $offset: Int, $id: ID) {
Room(first: $first, offset: $offset, id: $id, orderBy: createdAt_desc) { Room(first: $first, offset: $offset, id: $id, orderBy: lastMessageAt_desc) {
id id
roomId roomId
roomName roomName
avatar
lastMessageAt lastMessageAt
unreadCount unreadCount
lastMessage { lastMessage {

View File

@ -9,10 +9,9 @@ function walkRecursive(data, fields, fieldName, callback, _key?) {
if (!Array.isArray(fields)) { if (!Array.isArray(fields)) {
throw new Error('please provide an fields array for the walkRecursive helper') throw new Error('please provide an fields array for the walkRecursive helper')
} }
if (data && typeof data === 'string' && fields.includes(_key)) { const fieldDef = fields.find((f) => f.field === _key)
// well we found what we searched for, lets replace the value with our callback result if (data && typeof data === 'string' && fieldDef) {
const key = _key.split('!') if (!fieldDef.excludes?.includes(fieldName)) data = callback(data, _key)
if (key.length === 1 || key[1] !== fieldName) data = callback(data, key[0])
} else if (data && Array.isArray(data)) { } else if (data && Array.isArray(data)) {
// go into the rabbit hole and dig through that array // go into the rabbit hole and dig through that array
data.forEach((res, index) => { data.forEach((res, index) => {

View File

@ -54,4 +54,7 @@ export default {
Mutation: { Mutation: {
CreateRoom: roomProperties, CreateRoom: roomProperties,
}, },
Subscription: {
chatMessageAdded: messageProperties,
},
} }

View File

@ -30,6 +30,7 @@ const standardSanitizeHtmlOptions = {
'strike', 'strike',
'span', 'span',
'blockquote', 'blockquote',
'usertag',
], ],
allowedAttributes: { allowedAttributes: {
a: ['href', 'class', 'target', 'data-*', 'contenteditable'], a: ['href', 'class', 'target', 'data-*', 'contenteditable'],

View File

@ -3,11 +3,11 @@ import { cleanHtml } from '../middleware/helpers/cleanHtml'
// exclamation mark separetes field names, that should not be sanitized // exclamation mark separetes field names, that should not be sanitized
const fields = [ const fields = [
'content', { field: 'content', excludes: ['CreateMessage', 'Message'] },
'contentExcerpt', { field: 'contentExcerpt' },
'reasonDescription', { field: 'reasonDescription' },
'description!embed', { field: 'description', excludes: ['embed'] },
'descriptionExcerpt', { field: 'descriptionExcerpt' },
] ]
export default { export default {

View File

@ -117,7 +117,7 @@ describe('Message', () => {
}) })
describe('user chats in room', () => { describe('user chats in room', () => {
it('returns the message and publishes subscription', async () => { it('returns the message and publishes subscriptions', async () => {
await expect( await expect(
mutate({ mutate({
mutation: createMessageMutation(), mutation: createMessageMutation(),
@ -146,6 +146,20 @@ describe('Message', () => {
roomCountUpdated: '1', roomCountUpdated: '1',
userId: 'other-chatting-user', 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', () => { describe('room is updated as well', () => {

View File

@ -1,7 +1,9 @@
import { neo4jgraphql } from 'neo4j-graphql-js' import { neo4jgraphql } from 'neo4j-graphql-js'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
import { getUnreadRoomsCount } from './rooms' import { getUnreadRoomsCount } from './rooms'
import { pubsub, ROOM_COUNT_UPDATED } from '../../server' import { pubsub, ROOM_COUNT_UPDATED, CHAT_MESSAGE_ADDED } from '../../server'
import { withFilter } from 'graphql-subscriptions'
const setMessagesAsDistributed = async (undistributedMessagesIds, session) => { const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
return session.writeTransaction(async (transaction) => { return session.writeTransaction(async (transaction) => {
@ -19,6 +21,16 @@ const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
} }
export default { export default {
Subscription: {
chatMessageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(CHAT_MESSAGE_ADDED),
(payload, variables) => {
return payload.userId === variables.userId
},
),
},
},
Query: { Query: {
Message: async (object, params, context, resolveInfo) => { Message: async (object, params, context, resolveInfo) => {
const { roomId } = params const { roomId } = params
@ -69,7 +81,7 @@ export default {
createdAt: toString(datetime()), createdAt: toString(datetime()),
id: apoc.create.uuid(), id: apoc.create.uuid(),
indexId: CASE WHEN maxIndex IS NOT NULL THEN maxIndex + 1 ELSE 0 END, indexId: CASE WHEN maxIndex IS NOT NULL THEN maxIndex + 1 ELSE 0 END,
content: $content, content: LEFT($content,2000),
saved: true, saved: true,
distributed: false, distributed: false,
seen: false seen: false
@ -77,6 +89,7 @@ export default {
SET room.lastMessageAt = toString(datetime()) SET room.lastMessageAt = toString(datetime())
RETURN message { RETURN message {
.*, .*,
indexId: toString(message.indexId),
recipientId: recipientUser.id, recipientId: recipientUser.id,
senderId: currentUser.id, senderId: currentUser.id,
username: currentUser.name, username: currentUser.name,
@ -102,10 +115,14 @@ export default {
const roomCountUpdated = await getUnreadRoomsCount(message.recipientId, session) const roomCountUpdated = await getUnreadRoomsCount(message.recipientId, session)
// send subscriptions // send subscriptions
await pubsub.publish(ROOM_COUNT_UPDATED, { void pubsub.publish(ROOM_COUNT_UPDATED, {
roomCountUpdated, roomCountUpdated,
userId: message.recipientId, userId: message.recipientId,
}) })
void pubsub.publish(CHAT_MESSAGE_ADDED, {
chatMessageAdded: message,
userId: message.recipientId,
})
} }
return message return message

View File

@ -423,125 +423,147 @@ describe('Room', () => {
}) })
it('returns the rooms paginated', async () => { it('returns the rooms paginated', async () => {
expect(await query({ query: roomQuery(), variables: { first: 3, offset: 0 } })).toMatchObject( await expect(
{ query({ query: roomQuery(), variables: { first: 3, offset: 0 } }),
errors: undefined, ).resolves.toMatchObject({
data: { errors: undefined,
Room: [ 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), id: expect.any(String),
roomId: expect.any(String), content: '2nd message to other chatting user',
roomName: 'Third Chatting User', senderId: 'chatting-user',
users: expect.arrayContaining([ username: 'Chatting User',
{ avatar: expect.any(String),
_id: 'chatting-user', date: expect.any(String),
id: 'chatting-user', saved: true,
name: 'Chatting User', distributed: false,
avatar: { seen: false,
url: expect.any(String),
},
},
{
_id: 'third-chatting-user',
id: 'third-chatting-user',
name: 'Third Chatting User',
avatar: {
url: expect.any(String),
},
},
]),
}, },
{ users: expect.arrayContaining([
id: expect.any(String), expect.objectContaining({
roomId: expect.any(String), _id: 'chatting-user',
roomName: 'Second Chatting User', id: 'chatting-user',
users: expect.arrayContaining([ name: 'Chatting User',
{ avatar: {
_id: 'chatting-user', url: expect.any(String),
id: 'chatting-user',
name: 'Chatting User',
avatar: {
url: expect.any(String),
},
}, },
{ }),
_id: 'second-chatting-user', expect.objectContaining({
id: 'second-chatting-user', _id: 'other-chatting-user',
name: 'Second Chatting User', id: 'other-chatting-user',
avatar: { name: 'Other Chatting User',
url: expect.any(String), avatar: {
}, url: expect.any(String),
}, },
]), }),
}, ]),
{ }),
id: expect.any(String), ]),
roomId: expect.any(String),
roomName: 'Not Chatting User',
users: expect.arrayContaining([
{
_id: 'chatting-user',
id: 'chatting-user',
name: 'Chatting User',
avatar: {
url: expect.any(String),
},
},
{
_id: 'not-chatting-user',
id: 'not-chatting-user',
name: 'Not Chatting User',
avatar: {
url: expect.any(String),
},
},
]),
},
],
},
}, },
) })
expect(await query({ query: roomQuery(), variables: { first: 3, offset: 3 } })).toMatchObject( await expect(
{ query({ query: roomQuery(), variables: { first: 3, offset: 3 } }),
errors: undefined, ).resolves.toMatchObject({
data: { errors: undefined,
Room: [ data: {
{ Room: [
id: expect.any(String), expect.objectContaining({
roomId: expect.any(String), id: expect.any(String),
roomName: 'Other Chatting User', roomId: expect.any(String),
users: expect.arrayContaining([ roomName: 'Not Chatting User',
{ users: expect.arrayContaining([
_id: 'chatting-user', {
id: 'chatting-user', _id: 'chatting-user',
name: 'Chatting User', id: 'chatting-user',
avatar: { name: 'Chatting User',
url: expect.any(String), avatar: {
}, url: expect.any(String),
}, },
{ },
_id: 'other-chatting-user', {
id: 'other-chatting-user', _id: 'not-chatting-user',
name: 'Other Chatting User', id: 'not-chatting-user',
avatar: { name: 'Not Chatting User',
url: expect.any(String), avatar: {
}, url: expect.any(String),
}, },
]), },
}, ]),
], }),
}, ],
}, },
) })
}) })
}) })
describe('query single room', () => { describe('query single room', () => {
let result: any = null let result: any = null
beforeAll(async () => { beforeAll(async () => {
authenticatedUser = await chattingUser.toJson() authenticatedUser = await chattingUser.toJson()
result = await query({ query: roomQuery() }) result = await query({ query: roomQuery() })
}) })
describe('as chatter of room', () => { describe('as chatter of room', () => {
it('returns the room', async () => { it('returns the room', async () => {
expect( expect(
@ -556,34 +578,19 @@ describe('Room', () => {
{ {
id: expect.any(String), id: expect.any(String),
roomId: expect.any(String), roomId: expect.any(String),
roomName: 'Third Chatting User', roomName: result.data.Room[0].roomName,
users: expect.arrayContaining([ users: expect.any(Array),
{
_id: 'chatting-user',
id: 'chatting-user',
name: 'Chatting User',
avatar: {
url: expect.any(String),
},
},
{
_id: 'third-chatting-user',
id: 'third-chatting-user',
name: 'Third Chatting User',
avatar: {
url: expect.any(String),
},
},
]),
}, },
], ],
}, },
}) })
}) })
describe('as not chatter of room', () => { describe('as not chatter of room', () => {
beforeAll(async () => { beforeAll(async () => {
authenticatedUser = await notChattingUser.toJson() authenticatedUser = await notChattingUser.toJson()
}) })
it('returns no room', async () => { it('returns no room', async () => {
authenticatedUser = await notChattingUser.toJson() authenticatedUser = await notChattingUser.toJson()
expect( expect(

View File

@ -44,3 +44,7 @@ type Query {
orderBy: [_MessageOrdering] orderBy: [_MessageOrdering]
): [Message] ): [Message]
} }
type Subscription {
chatMessageAdded(userId: ID!): Message
}

View File

@ -7,6 +7,7 @@
# TODO change this to last message date # TODO change this to last message date
enum _RoomOrdering { enum _RoomOrdering {
lastMessageAt_desc
createdAt_desc createdAt_desc
} }

View File

@ -14,7 +14,7 @@ import bodyParser from 'body-parser'
import { graphqlUploadExpress } from 'graphql-upload' import { graphqlUploadExpress } from 'graphql-upload'
export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED' export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED'
// export const CHAT_MESSAGE_ADDED = 'CHAT_MESSAGE_ADDED' export const CHAT_MESSAGE_ADDED = 'CHAT_MESSAGE_ADDED'
export const ROOM_COUNT_UPDATED = 'ROOM_COUNT_UPDATED' export const ROOM_COUNT_UPDATED = 'ROOM_COUNT_UPDATED'
const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG
let prodPubsub, devPubsub let prodPubsub, devPubsub

View File

@ -27,4 +27,8 @@
chatMessageBgOthers: $chat-message-bg-others; chatMessageBgOthers: $chat-message-bg-others;
chatNewMessageColor: $chat-new-message-color; 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-message-bg-others: $color-neutral-80;
$chat-sidemenu-bg: $color-secondary-active; $chat-sidemenu-bg: $color-secondary-active;
$chat-new-message-color: $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

@ -0,0 +1,67 @@
<template>
<div class="add-chat-room-by-user-search">
<ds-flex class="headline">
<h2 class="title">{{ $t('chat.addRoomHeadline') }}</h2>
<base-button class="close-button" icon="close" circle @click="closeUserSearch" />
</ds-flex>
<ds-space margin-bottom="small" />
<ds-space>
<select-user-search :id="id" ref="selectUserSearch" @select-user="selectUser" />
</ds-space>
</div>
</template>
<script>
import SelectUserSearch from '~/components/generic/SelectUserSearch/SelectUserSearch'
export default {
name: 'AddChatRoomByUserSearch',
components: {
SelectUserSearch,
},
props: {
// chatRooms: {
// type: Array,
// default: [],
// },
},
data() {
return {
id: 'search-user-to-add-to-group',
user: {},
}
},
methods: {
selectUser(user) {
this.user = user
// if (this.groupMembers.find((member) => member.id === this.user.id)) {
// this.$toast.error(this.$t('group.errors.userAlreadyMember', { name: this.user.name }))
// this.$refs.selectUserSearch.clear()
// return
// }
this.$refs.selectUserSearch.clear()
this.$emit('close-user-search')
this.addChatRoom(this.user?.id)
},
async addChatRoom(userId) {
this.$emit('add-chat-room', userId)
},
closeUserSearch() {
this.$emit('close-user-search')
},
},
}
</script>
<style lang="scss">
.add-chat-room-by-user-search {
background-color: white;
padding: $space-base;
}
.ds-flex.headline {
justify-content: space-between;
}
.ds-flex.headline .close-button {
margin-top: -6px;
}
</style>

View File

@ -4,7 +4,7 @@
<vue-advanced-chat <vue-advanced-chat
:theme="theme" :theme="theme"
:current-user-id="currentUser.id" :current-user-id="currentUser.id"
:room-id="null" :room-id="computedRoomId"
:template-actions="JSON.stringify(templatesText)" :template-actions="JSON.stringify(templatesText)"
:menu-actions="JSON.stringify(menuActions)" :menu-actions="JSON.stringify(menuActions)"
:text-messages="JSON.stringify(textMessages)" :text-messages="JSON.stringify(textMessages)"
@ -19,23 +19,42 @@
show-audio="false" show-audio="false"
:styles="JSON.stringify(computedChatStyle)" :styles="JSON.stringify(computedChatStyle)"
:show-footer="true" :show-footer="true"
@send-message="sendMessage($event.detail[0])"
@fetch-messages="fetchMessages($event.detail[0])"
@fetch-more-rooms="fetchRooms"
:responsive-breakpoint="responsiveBreakpoint" :responsive-breakpoint="responsiveBreakpoint"
:single-room="singleRoom" :single-room="singleRoom"
show-reaction-emojis="false" show-reaction-emojis="false"
@send-message="sendMessage($event.detail[0])"
@fetch-messages="fetchMessages($event.detail[0])"
@fetch-more-rooms="fetchRooms"
@add-room="toggleUserSearch"
@show-demo-options="showDemoOptions = $event" @show-demo-options="showDemoOptions = $event"
> >
<div slot="menu-icon" @click.prevent.stop="$emit('close-single-room', true)"> <div
<div v-if="singleRoom"> v-if="selectedRoom && selectedRoom.roomId"
<ds-icon name="close"></ds-icon> slot="room-options"
</div> class="chat-room-options"
>
<ds-flex v-if="singleRoom">
<ds-flex-item centered class="single-chat-bubble">
<nuxt-link :to="{ name: 'chat' }">
<base-icon name="chat-bubble" />
</nuxt-link>
</ds-flex-item>
<ds-flex-item centered>
<div
class="vac-svg-button vac-room-options"
@click="$emit('close-single-room', true)"
>
<slot name="menu-icon">
<ds-icon name="close" />
</slot>
</div>
</ds-flex-item>
</ds-flex>
</div> </div>
<div slot="room-header-avatar"> <div slot="room-header-avatar">
<div <div
v-if="selectedRoom && selectedRoom.avatar && selectedRoom.avatar !== 'default-avatar'" v-if="selectedRoom && selectedRoom.avatar"
class="vac-avatar" class="vac-avatar"
:style="{ 'background-image': `url('${selectedRoom.avatar}')` }" :style="{ 'background-image': `url('${selectedRoom.avatar}')` }"
/> />
@ -46,7 +65,7 @@
<div v-for="room in rooms" :slot="'room-list-avatar_' + room.id" :key="room.id"> <div v-for="room in rooms" :slot="'room-list-avatar_' + room.id" :key="room.id">
<div <div
v-if="room.avatar && room.avatar !== 'default-avatar'" v-if="room.avatar"
class="vac-avatar" class="vac-avatar"
:style="{ 'background-image': `url('${room.avatar}')` }" :style="{ 'background-image': `url('${room.avatar}')` }"
/> />
@ -61,7 +80,12 @@
<script> <script>
import { roomQuery, createRoom, unreadRoomsQuery } from '~/graphql/Rooms' import { roomQuery, createRoom, unreadRoomsQuery } from '~/graphql/Rooms'
import { messageQuery, createMessageMutation, markMessagesAsSeen } from '~/graphql/Messages' import {
messageQuery,
createMessageMutation,
chatMessageAdded,
markMessagesAsSeen,
} from '~/graphql/Messages'
import chatStyle from '~/constants/chat.js' import chatStyle from '~/constants/chat.js'
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
@ -70,8 +94,13 @@ export default {
props: { props: {
theme: { theme: {
type: String, type: String,
default: 'light',
}, },
singleRoomId: { singleRoom: {
type: Boolean,
default: false,
},
roomId: {
type: String, type: String,
default: null, default: null,
}, },
@ -79,11 +108,6 @@ export default {
data() { data() {
return { return {
menuActions: [ menuActions: [
// NOTE: if menuActions is empty, the related slot is not shown
{
name: 'dummyItem',
title: 'Just a dummy item',
},
/* /*
{ {
name: 'inviteUser', name: 'inviteUser',
@ -139,8 +163,7 @@ export default {
roomsLoaded: false, roomsLoaded: false,
roomPage: 0, roomPage: 0,
roomPageSize: 10, roomPageSize: 10,
singleRoom: !!this.singleRoomId || false, selectedRoom: this.roomId,
selectedRoom: null,
loadingRooms: true, loadingRooms: true,
messagesLoaded: false, messagesLoaded: false,
messagePage: 0, messagePage: 0,
@ -150,33 +173,47 @@ export default {
}, },
mounted() { mounted() {
if (this.singleRoom) { if (this.singleRoom) {
this.$apollo this.newRoom(this.roomId)
.mutate({
mutation: createRoom(),
variables: {
userId: this.singleRoomId,
},
})
.then(({ data: { CreateRoom } }) => {
this.fetchRooms({ room: CreateRoom })
})
.catch((error) => {
this.$toast.error(error)
})
.finally(() => {
// this.loading = false
})
} else { } else {
this.fetchRooms() 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: { computed: {
...mapGetters({ ...mapGetters({
currentUser: 'auth/user', currentUser: 'auth/user',
getStoreRoomId: 'chat/roomID',
}), }),
computedChatStyle() { computedChatStyle() {
return chatStyle.STYLE.light return chatStyle.STYLE.light
}, },
computedRoomId() {
let roomId = null
if (!this.singleRoom) {
roomId = this.roomId
if (this.getStoreRoomId.roomId) {
roomId = this.getStoreRoomId.roomId
}
}
return roomId
},
textMessages() { textMessages() {
return { return {
ROOMS_EMPTY: this.$t('chat.roomsEmpty'), ROOMS_EMPTY: this.$t('chat.roomsEmpty'),
@ -197,7 +234,9 @@ export default {
methods: { methods: {
...mapMutations({ ...mapMutations({
commitUnreadRoomCount: 'chat/UPDATE_ROOM_COUNT', commitUnreadRoomCount: 'chat/UPDATE_ROOM_COUNT',
commitRoomIdFromSingleRoom: 'chat/UPDATE_ROOM_ID',
}), }),
async fetchRooms({ room } = {}) { async fetchRooms({ room } = {}) {
this.roomsLoaded = false this.roomsLoaded = false
const offset = this.roomPage * this.roomPageSize const offset = this.roomPage * this.roomPageSize
@ -214,21 +253,27 @@ export default {
fetchPolicy: 'no-cache', fetchPolicy: 'no-cache',
}) })
const newRooms = Room.map((r) => { const rms = []
return { const rmsIds = []
...r, ;[...Room, ...this.rooms].forEach((r) => {
users: r.users.map((u) => { if (!rmsIds.find((v) => v === r.id)) {
return { ...u, username: u.name, avatar: u.avatar?.url } rms.push(this.fixRoomObject(r))
}), rmsIds.push(r.id)
} }
}) })
this.rooms = rms
this.rooms = [...this.rooms, ...newRooms]
if (Room.length < this.roomPageSize) { if (Room.length < this.roomPageSize) {
this.roomsLoaded = true this.roomsLoaded = true
} }
this.roomPage += 1 this.roomPage += 1
if (this.singleRoom && this.rooms.length > 0) {
this.commitRoomIdFromSingleRoom(this.rooms[0].roomId)
} else if (this.getStoreRoomId.roomId) {
// reset store room id
this.commitRoomIdFromSingleRoom(null)
}
} catch (error) { } catch (error) {
this.rooms = [] this.rooms = []
this.$toast.error(error.message) this.$toast.error(error.message)
@ -258,8 +303,14 @@ export default {
fetchPolicy: 'no-cache', fetchPolicy: 'no-cache',
}) })
const newMsgIds = Message.filter((m) => m.seen === false).map((m) => m.id) const newMsgIds = Message.filter(
(m) => m.seen === false && m.senderId !== this.currentUser.id,
).map((m) => m.id)
if (newMsgIds.length) { 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 this.$apollo
.mutate({ .mutate({
mutation: markMessagesAsSeen(), mutation: markMessagesAsSeen(),
@ -297,21 +348,41 @@ export default {
} }
}, },
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) { 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 { try {
await this.$apollo.mutate({ const {
data: { CreateMessage: createdMessage },
} = await this.$apollo.mutate({
mutation: createMessageMutation(), mutation: createMessageMutation(),
variables: { variables: {
roomId: message.roomId, roomId: message.roomId,
content: message.content, 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)
// move current room to top (not 100% working)
// const rooms = [...this.rooms]
// rooms.splice(roomIndex,1)
// this.rooms = [changedRoom, ...rooms]
this.rooms[roomIndex] = changedRoom
} catch (error) { } catch (error) {
this.$toast.error(error.message) this.$toast.error(error.message)
} }
@ -325,6 +396,58 @@ export default {
if (!fullname) return if (!fullname) return
return fullname.match(/\b\w/g).join('').substring(0, 3).toUpperCase() return fullname.match(/\b\w/g).join('').substring(0, 3).toUpperCase()
}, },
toggleUserSearch() {
this.$emit('toggle-user-search')
},
fixRoomObject(room) {
// This fixes the room object which arrives from the backend
const fixedRoom = {
...room,
index: room.lastMessage ? room.lastMessage.date : room.createdAt,
lastMessage: room.lastMessage
? {
...room.lastMessage,
content: room.lastMessage?.content?.trim().substring(0, 30),
}
: null,
users: room.users.map((u) => {
return { ...u, username: u.name, avatar: u.avatar?.url }
}),
}
if (!fixedRoom.avatar) {
// as long as we cannot query avatar on CreateRoom
fixedRoom.avatar = fixedRoom.users.find((u) => u.id !== this.currentUser.id).avatar
}
return fixedRoom
},
newRoom(userId) {
this.$apollo
.mutate({
mutation: createRoom(),
variables: {
userId,
},
})
.then(({ data: { CreateRoom } }) => {
const roomIndex = this.rooms.findIndex((r) => r.id === CreateRoom.roomId)
const room = this.fixRoomObject(CreateRoom)
if (roomIndex === -1) {
this.rooms = [room, ...this.rooms]
}
this.fetchMessages({ room, options: { refetch: true } })
this.$emit('show-chat', CreateRoom.id)
})
.catch((error) => {
this.$toast.error(error.message)
})
.finally(() => {
// this.loading = false
})
},
}, },
} }
</script> </script>
@ -353,4 +476,8 @@ body {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
} }
.ds-flex-item.single-chat-bubble {
margin-right: 1em;
}
</style> </style>

View File

@ -8,7 +8,6 @@
:disabled="disabled && !update" :disabled="disabled && !update"
@click="handleCancel" @click="handleCancel"
data-test="cancel-button" data-test="cancel-button"
danger
> >
{{ $t('actions.cancel') }} {{ $t('actions.cancel') }}
</base-button> </base-button>

View File

@ -58,14 +58,14 @@ export default {
routes.push({ routes.push({
label: this.$t('group.contentMenu.visitGroupPage'), label: this.$t('group.contentMenu.visitGroupPage'),
icon: 'home', icon: 'home',
name: 'group-id-slug', path: `/groups/${this.group.id}`,
params: { id: this.group.id, slug: this.group.slug }, params: { id: this.group.id, slug: this.group.slug },
}) })
} }
if (this.group.myRole === 'owner') { if (this.group.myRole === 'owner') {
routes.push({ routes.push({
label: this.$t('admin.settings.name'), label: this.$t('admin.settings.name'),
path: `/group/edit/${this.group.id}`, path: `/groups/edit/${this.group.id}`,
icon: 'edit', icon: 'edit',
}) })
} }

View File

@ -169,12 +169,7 @@
</ds-flex-item> </ds-flex-item>
<ds-flex-item width="0.15" /> <ds-flex-item width="0.15" />
<ds-flex-item class="action-buttons-group" width="2"> <ds-flex-item class="action-buttons-group" width="2">
<base-button <base-button data-test="cancel-button" :disabled="loading" @click="$router.back()">
data-test="cancel-button"
:disabled="loading"
@click="$router.back()"
danger
>
{{ $t('actions.cancel') }} {{ $t('actions.cancel') }}
</base-button> </base-button>
<base-button type="submit" icon="check" :loading="loading" :disabled="errors" filled> <base-button type="submit" icon="check" :loading="loading" :disabled="errors" filled>

View File

@ -3,33 +3,7 @@
<h2 class="title">{{ $t('group.addUser') }}</h2> <h2 class="title">{{ $t('group.addUser') }}</h2>
<ds-space margin-bottom="small" /> <ds-space margin-bottom="small" />
<ds-space> <ds-space>
<ds-select <select-user-search :id="id" ref="selectUserSearch" @select-user="selectUser" />
type="search"
icon="search"
label-prop="id"
v-model="query"
:id="id"
:icon-right="null"
:options="users"
:loading="$apollo.queries.searchUsers.loading"
:filter="(item) => item"
:no-options-available="$t('group.addUserNoOptions')"
:auto-reset-search="true"
:placeholder="$t('group.addUserPlaceholder')"
@focus.capture.native="onFocus"
@input.native="handleInput"
@keyup.enter.native="onEnter"
@keyup.delete.native="onDelete"
@keyup.esc.native="clear"
@blur.capture.native="onBlur"
@input.exact="onSelect"
>
<template #option="{ option }">
<p>
<user-teaser :user="option" :showPopover="false" :linkToProfile="false" />
</p>
</template>
</ds-select>
<ds-modal <ds-modal
v-if="isOpen" v-if="isOpen"
force force
@ -49,16 +23,15 @@
</ds-space> </ds-space>
</div> </div>
</template> </template>
<script> <script>
import { changeGroupMemberRoleMutation } from '~/graphql/groups.js' import { changeGroupMemberRoleMutation } from '~/graphql/groups.js'
import { searchUsers } from '~/graphql/Search.js' import SelectUserSearch from '~/components/generic/SelectUserSearch/SelectUserSearch'
import UserTeaser from '~/components/UserTeaser/UserTeaser.vue'
import { isEmpty } from 'lodash'
export default { export default {
name: 'AddGroupMember', name: 'AddGroupMember',
components: { components: {
UserTeaser, SelectUserSearch,
}, },
props: { props: {
groupId: { groupId: {
@ -72,62 +45,34 @@ export default {
}, },
data() { data() {
return { return {
users: [],
id: 'search-user-to-add-to-group', id: 'search-user-to-add-to-group',
query: '',
user: {}, user: {},
isOpen: false, isOpen: false,
} }
}, },
computed: {
startSearch() {
return this.query && this.query.length > 3
},
},
methods: { methods: {
cancelModal() { cancelModal() {
this.clear() this.$refs.selectUserSearch.clear()
this.isOpen = false this.isOpen = false
}, },
closeModal() { closeModal() {
this.clear() this.$refs.selectUserSearch.clear()
this.isOpen = false this.isOpen = false
}, },
confirmModal() { confirmModal() {
this.addMemberToGroup() this.addMemberToGroup()
this.isOpen = false this.isOpen = false
this.clear() this.$refs.selectUserSearch.clear()
}, },
onFocus() {}, selectUser(user) {
onBlur() { this.user = user
this.query = ''
},
handleInput(event) {
this.query = event.target ? event.target.value.trim() : ''
},
onDelete(event) {
const value = event.target ? event.target.value.trim() : ''
if (isEmpty(value)) {
this.clear()
} else {
this.handleInput(event)
}
},
clear() {
this.query = ''
this.user = {}
this.users = []
},
onSelect(item) {
this.user = item
if (this.groupMembers.find((member) => member.id === this.user.id)) { if (this.groupMembers.find((member) => member.id === this.user.id)) {
this.$toast.error(this.$t('group.errors.userAlreadyMember', { name: this.user.name })) this.$toast.error(this.$t('group.errors.userAlreadyMember', { name: this.user.name }))
this.clear() this.$refs.selectUserSearch.clear()
return return
} }
this.isOpen = true this.isOpen = true
}, },
onEnter() {},
async addMemberToGroup() { async addMemberToGroup() {
const newRole = 'usual' const newRole = 'usual'
const username = this.user.name const username = this.user.name
@ -148,29 +93,9 @@ export default {
} }
}, },
}, },
apollo: {
searchUsers: {
query() {
return searchUsers
},
variables() {
return {
query: this.query,
firstUsers: 5,
usersOffset: 0,
}
},
skip() {
return !this.startSearch
},
update({ searchUsers }) {
this.users = searchUsers.users
},
fetchPolicy: 'cache-and-network',
},
},
} }
</script> </script>
<style lang="scss"> <style lang="scss">
.add-group-member { .add-group-member {
background-color: white; background-color: white;

View File

@ -1,7 +1,7 @@
<template> <template>
<nuxt-link <nuxt-link
class="group-teaser" class="group-teaser"
:to="{ name: 'group-id-slug', params: { id: group.id, slug: group.slug } }" :to="{ name: 'groups-id-slug', params: { id: group.id, slug: group.slug } }"
> >
<base-card <base-card
:class="{ :class="{

View File

@ -9,7 +9,7 @@
<p class="description">{{ $t(`notifications.reason.${notification.reason}`) }}</p> <p class="description">{{ $t(`notifications.reason.${notification.reason}`) }}</p>
<nuxt-link <nuxt-link
class="link" class="link"
:to="{ name: isGroup ? 'group-id-slug' : 'post-id-slug', params, ...hashParam }" :to="{ name: isGroup ? 'groups-id-slug' : 'post-id-slug', params, ...hashParam }"
@click.native="$emit('read')" @click.native="$emit('read')"
> >
<base-card wideContent> <base-card wideContent>

View File

@ -65,7 +65,7 @@
class="notification-mention-post" class="notification-mention-post"
:class="{ 'notification-status': notification.read }" :class="{ 'notification-status': notification.read }"
:to="{ :to="{
name: isGroup(notification.from) ? 'group-id-slug' : 'post-id-slug', name: isGroup(notification.from) ? 'groups-id-slug' : 'post-id-slug',
params: params(notification.from), params: params(notification.from),
hash: hashParam(notification.from), hash: hashParam(notification.from),
}" }"

View File

@ -96,7 +96,7 @@ export default {
groupLink() { groupLink() {
const { id, slug } = this.group const { id, slug } = this.group
if (!(id && slug)) return '' if (!(id && slug)) return ''
return { name: 'group-id-slug', params: { slug, id } } return { name: 'groups-id-slug', params: { slug, id } }
}, },
groupSlug() { groupSlug() {
const { slug } = this.group || {} const { slug } = this.group || {}

View File

@ -56,7 +56,7 @@
> >
{{ isEditing ? $t('actions.save') : texts.addButton }} {{ isEditing ? $t('actions.save') : texts.addButton }}
</base-button> </base-button>
<base-button v-if="isEditing" id="cancel" danger @click="handleCancel()"> <base-button v-if="isEditing" id="cancel" @click="handleCancel()">
{{ $t('actions.cancel') }} {{ $t('actions.cancel') }}
</base-button> </base-button>
</ds-space> </ds-space>

View File

@ -148,7 +148,7 @@ export default {
case 'User': case 'User':
return 'profile-id-slug' return 'profile-id-slug'
case 'Group': case 'Group':
return 'group-id-slug' return 'groups-id-slug'
default: default:
return null return null
} }

View File

@ -0,0 +1,109 @@
<template>
<ds-select
class="select-user-search"
type="search"
icon="search"
label-prop="id"
v-model="query"
:id="id"
:icon-right="null"
:options="users"
:loading="$apollo.queries.searchUsers.loading"
:filter="(item) => item"
:no-options-available="$t('group.addUserNoOptions')"
:auto-reset-search="true"
:placeholder="$t('group.addUserPlaceholder')"
@focus.capture.native="onFocus"
@input.native="handleInput"
@keyup.enter.native="onEnter"
@keyup.delete.native="onDelete"
@keyup.esc.native="clear"
@blur.capture.native="onBlur"
@input.exact="onSelect"
>
<template #option="{ option }">
<p>
<user-teaser :user="option" :showPopover="false" :linkToProfile="false" />
</p>
</template>
</ds-select>
</template>
<script>
import { isEmpty } from 'lodash'
import { searchUsers } from '~/graphql/Search.js'
import UserTeaser from '~/components/UserTeaser/UserTeaser.vue'
export default {
name: 'SelectUserSearch',
components: {
UserTeaser,
},
props: {
id: {
type: String,
required: true,
},
},
data() {
return {
users: [],
query: '',
user: {},
}
},
computed: {
startSearch() {
return this.query && this.query.length > 3
},
},
methods: {
onFocus() {},
onBlur() {
this.query = ''
},
handleInput(event) {
this.query = event.target ? event.target.value.trim() : ''
},
onDelete(event) {
const value = event.target ? event.target.value.trim() : ''
if (isEmpty(value)) {
this.clear()
} else {
this.handleInput(event)
}
},
clear() {
this.query = ''
this.user = {}
this.users = []
},
onSelect(item) {
this.user = item
this.$emit('select-user', this.user)
},
onEnter() {},
},
apollo: {
searchUsers: {
query() {
return searchUsers
},
variables() {
return {
query: this.query,
firstUsers: 5,
usersOffset: 0,
}
},
skip() {
return !this.startSearch
},
update({ searchUsers }) {
this.users = searchUsers.users
},
fetchPolicy: 'cache-and-network',
},
},
}
</script>

View File

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

View File

@ -1,5 +1,31 @@
import gql from 'graphql-tag' import gql from 'graphql-tag'
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 = () => { export const messageQuery = () => {
return gql` return gql`
query ($roomId: ID!, $first: Int, $offset: Int) { query ($roomId: ID!, $first: Int, $offset: Int) {
@ -15,6 +41,9 @@ export const messageQuery = () => {
username username
avatar avatar
date date
room {
id
}
saved saved
distributed distributed
seen seen
@ -23,12 +52,27 @@ export const messageQuery = () => {
` `
} }
export const createMessageMutation = () => { export const chatMessageAdded = () => {
return gql` return gql`
mutation ($roomId: ID!, $content: String!) { subscription chatMessageAdded($userId: ID!) {
CreateMessage(roomId: $roomId, content: $content) { chatMessageAdded(userId: $userId) {
_id
id id
indexId
content content
senderId
author {
id
}
username
avatar
date
room {
id
}
saved
distributed
seen
} }
} }
` `

View File

@ -1,12 +1,15 @@
import gql from 'graphql-tag' import gql from 'graphql-tag'
export const roomQuery = () => gql` export const createRoom = () => gql`
query Room($first: Int, $offset: Int, $id: ID) { mutation ($userId: ID!) {
Room(first: $first, offset: $offset, id: $id, orderBy: createdAt_desc) { CreateRoom(userId: $userId) {
id id
roomId roomId
roomName roomName
avatar lastMessageAt
createdAt
unreadCount
#avatar
users { users {
_id _id
id id
@ -19,11 +22,36 @@ export const roomQuery = () => gql`
} }
` `
export const createRoom = () => gql` export const roomQuery = () => gql`
mutation ($userId: ID!) { query Room($first: Int, $offset: Int, $id: ID) {
CreateRoom(userId: $userId) { Room(first: $first, offset: $offset, id: $id, orderBy: [createdAt_desc, lastMessageAt_desc]) {
id id
roomId roomId
roomName
avatar
lastMessageAt
createdAt
unreadCount
lastMessage {
_id
id
content
senderId
username
avatar
date
saved
distributed
seen
}
users {
_id
id
name
avatar {
url
}
}
} }
} }
` `

View File

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

View File

@ -78,6 +78,7 @@
} }
}, },
"chat": { "chat": {
"addRoomHeadline": "Suche Nutzer für neuen Chat",
"cancelSelectMessage": "Abbrechen", "cancelSelectMessage": "Abbrechen",
"conversationStarted": "Unterhaltung startete am:", "conversationStarted": "Unterhaltung startete am:",
"isOnline": "online", "isOnline": "online",
@ -86,9 +87,12 @@
"messageDeleted": "Diese Nachricht wuerde gelöscht", "messageDeleted": "Diese Nachricht wuerde gelöscht",
"messagesEmpty": "Keine Nachrichten", "messagesEmpty": "Keine Nachrichten",
"newMessages": "Neue Nachrichten", "newMessages": "Neue Nachrichten",
"page": {
"headline": "Chat"
},
"roomEmpty": "Keinen Raum selektiert", "roomEmpty": "Keinen Raum selektiert",
"roomsEmpty": "Keine Räume", "roomsEmpty": "Keine Räume",
"search": "Suche", "search": "Chat-Räume filtern",
"typeMessage": "Nachricht schreiben", "typeMessage": "Nachricht schreiben",
"userProfileButton": { "userProfileButton": {
"label": "Chat", "label": "Chat",

View File

@ -78,6 +78,7 @@
} }
}, },
"chat": { "chat": {
"addRoomHeadline": "Search User for new Chat",
"cancelSelectMessage": "Cancel", "cancelSelectMessage": "Cancel",
"conversationStarted": "Conversation started on:", "conversationStarted": "Conversation started on:",
"isOnline": "is online", "isOnline": "is online",
@ -86,9 +87,12 @@
"messageDeleted": "This message was deleted", "messageDeleted": "This message was deleted",
"messagesEmpty": "No messages", "messagesEmpty": "No messages",
"newMessages": "New Messages", "newMessages": "New Messages",
"page": {
"headline": "Chat"
},
"roomEmpty": "No room selected", "roomEmpty": "No room selected",
"roomsEmpty": "No rooms", "roomsEmpty": "No rooms",
"search": "Search", "search": "Filter chat rooms",
"typeMessage": "Type message", "typeMessage": "Type message",
"userProfileButton": { "userProfileButton": {
"label": "Chat", "label": "Chat",
@ -472,7 +476,7 @@
"addMemberToGroupSuccess": "“{name}” was added to the group with the role “{role}”!", "addMemberToGroupSuccess": "“{name}” was added to the group with the role “{role}”!",
"addUser": "Add User", "addUser": "Add User",
"addUserNoOptions": "No users found!", "addUserNoOptions": "No users found!",
"addUserPlaceholder": " Username", "addUserPlaceholder": "User name",
"allGroups": "All Groups", "allGroups": "All Groups",
"button": { "button": {
"tooltip": "Show groups" "tooltip": "Show groups"

View File

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

View File

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

View File

@ -1,11 +1,54 @@
<template> <template>
<chat /> <div>
<ds-heading tag="h1">{{ $t('chat.page.headline') }}</ds-heading>
<add-chat-room-by-user-search
v-if="showUserSearch"
@add-chat-room="addChatRoom"
@close-user-search="showUserSearch = false"
/>
<ds-space margin-bottom="small" />
<chat
:roomId="getShowChat.showChat ? getShowChat.roomID : null"
ref="chat"
@toggle-user-search="showUserSearch = !showUserSearch"
:show-room="showRoom"
/>
</div>
</template> </template>
<script> <script>
import { mapGetters, mapMutations } from 'vuex'
import AddChatRoomByUserSearch from '~/components/Chat/AddChatRoomByUserSearch'
import Chat from '../components/Chat/Chat.vue' import Chat from '../components/Chat/Chat.vue'
export default { export default {
components: { Chat }, components: {
AddChatRoomByUserSearch,
Chat,
},
data() {
return {
showUserSearch: false,
}
},
mounted() {
this.showChat({ showChat: false, roomID: null })
},
computed: {
...mapGetters({
getShowChat: 'chat/showChat',
}),
},
methods: {
...mapMutations({
showChat: 'chat/SET_OPEN_CHAT',
}),
addChatRoom(userID) {
this.$refs.chat.newRoom(userID)
},
showRoom(roomId) {
this.showChat({ showChat: true, roomID: roomId })
},
},
} }
</script> </script>

View File

@ -24,7 +24,7 @@ const options = {
} }
`, `,
message: 'error-pages.group-not-found', message: 'error-pages.group-not-found',
path: 'group', path: 'groups',
} }
const persistentLinks = PersistentLinks(options) const persistentLinks = PersistentLinks(options)

View File

@ -59,7 +59,7 @@ export default {
}) })
this.$toast.success(this.$t('group.groupCreated')) this.$toast.success(this.$t('group.groupCreated'))
this.$router.history.push({ this.$router.history.push({
name: 'group-id-slug', name: 'groups-id-slug',
params: { id: responseId, slug: responseSlug }, params: { id: responseId, slug: responseSlug },
}) })
} catch (error) { } catch (error) {

View File

@ -33,11 +33,11 @@ export default {
return [ return [
{ {
name: this.$t('group.general'), name: this.$t('group.general'),
path: `/group/edit/${this.group.id}`, path: `/groups/edit/${this.group.id}`,
}, },
{ {
name: this.$t('group.members'), name: this.$t('group.members'),
path: `/group/edit/${this.group.id}/members`, path: `/groups/edit/${this.group.id}/members`,
}, },
] ]
}, },

View File

@ -60,7 +60,7 @@ export default {
}) })
this.$toast.success(this.$t('group.updatedGroup')) this.$toast.success(this.$t('group.updatedGroup'))
this.$router.history.push({ this.$router.history.push({
name: 'group-id-slug', name: 'groups-id-slug',
params: { id: responseId, slug: responseSlug }, params: { id: responseId, slug: responseSlug },
}) })
} catch (error) { } catch (error) {

View File

@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import groups from './groups.vue' import groups from './index.vue'
const localVue = global.localVue const localVue = global.localVue

View File

@ -7,7 +7,7 @@
<ds-space> <ds-space>
<!-- create group --> <!-- create group -->
<ds-space centered> <ds-space centered>
<nuxt-link :to="{ name: 'group-create' }"> <nuxt-link :to="{ name: 'groups-create' }">
<base-button <base-button
class="group-add-button" class="group-add-button"
icon="plus" icon="plus"

View File

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

View File

@ -14,6 +14,9 @@ export const mutations = {
UPDATE_ROOM_COUNT(state, count) { UPDATE_ROOM_COUNT(state, count) {
state.unreadRoomCount = count state.unreadRoomCount = count
}, },
UPDATE_ROOM_ID(state, roomid) {
state.roomId = roomid || null
},
} }
export const getters = { export const getters = {