feat(webapp): group chat (#9439)

This commit is contained in:
Ulf Gebhardt 2026-04-01 02:48:28 +02:00 committed by GitHub
parent 1be90b4976
commit e931d6e03b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 4055 additions and 642 deletions

View File

@ -1,3 +1,4 @@
export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED'
export const CHAT_MESSAGE_ADDED = 'CHAT_MESSAGE_ADDED'
export const CHAT_MESSAGE_STATUS_UPDATED = 'CHAT_MESSAGE_STATUS_UPDATED'
export const ROOM_COUNT_UPDATED = 'ROOM_COUNT_UPDATED'

View File

@ -0,0 +1,75 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { getDriver } from '@db/neo4j'
export const description =
'Replace global message.seen flag with per-user HAS_NOT_SEEN relationships'
export async function up(_next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// Create HAS_NOT_SEEN relationships for unseen messages
// For each message with seen=false, create a relationship for each room member
// who is not the sender
await transaction.run(`
MATCH (message:Message { seen: false })-[:INSIDE]->(room:Room)<-[:CHATS_IN]-(user:User)
WHERE NOT (user)-[:CREATED]->(message)
CREATE (user)-[:HAS_NOT_SEEN]->(message)
`)
// Remove the seen property from all messages
await transaction.run(`
MATCH (m:Message)
REMOVE m.seen
`)
await transaction.commit()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
await session.close()
}
}
export async function down(_next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
// Re-add seen property: messages with HAS_NOT_SEEN are unseen, rest are seen
await transaction.run(`
MATCH (m:Message)
SET m.seen = true
`)
await transaction.run(`
MATCH ()-[:HAS_NOT_SEEN]->(m:Message)
SET m.seen = false
`)
// Remove all HAS_NOT_SEEN relationships
await transaction.run(`
MATCH ()-[r:HAS_NOT_SEEN]->(:Message)
DELETE r
`)
await transaction.commit()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
await session.close()
}
}

View File

@ -0,0 +1,35 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { getDriver } from '@db/neo4j'
export const description =
'Delete empty DM rooms (no messages) that were created by the old CreateRoom mutation'
export async function up(_next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
await transaction.run(`
MATCH (room:Room)
WHERE NOT (room)-[:ROOM_FOR]->(:Group)
AND NOT (room)<-[:INSIDE]-(:Message)
DETACH DELETE room
`)
await transaction.commit()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
await session.close()
}
}
export async function down(_next) {
// Cannot restore deleted rooms
}

View File

@ -15,8 +15,8 @@ import CreateComment from '@graphql/queries/comments/CreateComment.gql'
import ChangeGroupMemberRole from '@graphql/queries/groups/ChangeGroupMemberRole.gql'
import CreateGroup from '@graphql/queries/groups/CreateGroup.gql'
import JoinGroup from '@graphql/queries/groups/JoinGroup.gql'
import CreateGroupRoom from '@graphql/queries/messaging/CreateGroupRoom.gql'
import CreateMessage from '@graphql/queries/messaging/CreateMessage.gql'
import CreateRoom from '@graphql/queries/messaging/CreateRoom.gql'
import CreatePost from '@graphql/queries/posts/CreatePost.gql'
import { createApolloTestSetup } from '@root/test/helpers'
@ -1531,87 +1531,139 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
// eslint-disable-next-line no-console
console.log('seed', 'chat')
// DM chat: Huey <-> Peter (first message creates room via userId)
authenticatedUser = await huey.toJson()
const { data: roomHueyPeter } = await mutate({
mutation: CreateRoom,
const { data: firstMsgHueyPeter } = await mutate({
mutation: CreateMessage,
variables: {
userId: (await peterLustig.toJson()).id,
content: faker.lorem.sentence(),
},
})
const roomIdHueyPeter = firstMsgHueyPeter?.CreateMessage.room.id
for (let i = 0; i < 30; i++) {
authenticatedUser = await huey.toJson()
await mutate({
mutation: CreateMessage,
variables: {
roomId: roomHueyPeter?.CreateRoom.id,
content: faker.lorem.sentence(),
},
})
for (let i = 0; i < 29; i++) {
authenticatedUser = await peterLustig.toJson()
await mutate({
mutation: CreateMessage,
variables: {
roomId: roomHueyPeter?.CreateRoom.id,
content: faker.lorem.sentence(),
},
variables: { roomId: roomIdHueyPeter, content: faker.lorem.sentence() },
})
}
authenticatedUser = await huey.toJson()
const { data: roomHueyJenny } = await mutate({
mutation: CreateRoom,
variables: {
userId: (await jennyRostock.toJson()).id,
},
})
for (let i = 0; i < 1000; i++) {
authenticatedUser = await huey.toJson()
await mutate({
mutation: CreateMessage,
variables: {
roomId: roomHueyJenny?.CreateRoom.id,
content: faker.lorem.sentence(),
},
variables: { roomId: roomIdHueyPeter, content: faker.lorem.sentence() },
})
}
// DM chat: Huey <-> Jenny (first message creates room via userId)
authenticatedUser = await huey.toJson()
const { data: firstMsgHueyJenny } = await mutate({
mutation: CreateMessage,
variables: {
userId: (await jennyRostock.toJson()).id,
content: faker.lorem.sentence(),
},
})
const roomIdHueyJenny = firstMsgHueyJenny?.CreateMessage.room.id
for (let i = 0; i < 999; i++) {
authenticatedUser = await jennyRostock.toJson()
await mutate({
mutation: CreateMessage,
variables: { roomId: roomIdHueyJenny, content: faker.lorem.sentence() },
})
authenticatedUser = await huey.toJson()
await mutate({
mutation: CreateMessage,
variables: { roomId: roomIdHueyJenny, content: faker.lorem.sentence() },
})
}
// DM chats: Jenny <-> additionalUsers
for (const user of additionalUsers.slice(0, 99)) {
authenticatedUser = await jennyRostock.toJson()
const { data: firstMsg } = await mutate({
mutation: CreateMessage,
variables: {
roomId: roomHueyJenny?.CreateRoom.id,
userId: (await user.toJson()).id,
content: faker.lorem.sentence(),
},
})
const dmRoomId = firstMsg?.CreateMessage.room.id
for (let i = 0; i < 28; i++) {
authenticatedUser = await user.toJson()
await mutate({
mutation: CreateMessage,
variables: { roomId: dmRoomId, content: faker.lorem.sentence() },
})
authenticatedUser = await jennyRostock.toJson()
await mutate({
mutation: CreateMessage,
variables: { roomId: dmRoomId, content: faker.lorem.sentence() },
})
}
}
// eslint-disable-next-line no-console
console.log('seed', 'group chat')
// Group g1 (School For Citizens) - active members: Jenny(owner/creator), Peter(usual), Bob(usual), Dewey(admin), Louie(owner), Dagobert(usual)
// Create group room as Jenny (creator of g1)
authenticatedUser = await jennyRostock.toJson()
const { data: roomG1 } = await mutate({
mutation: CreateGroupRoom,
variables: { groupId: 'g1' },
})
const g1RoomId = roomG1?.CreateGroupRoom.id
// Members have a conversation
const g1Members = [
{ user: jennyRostock, name: 'Jenny' },
{ user: peterLustig, name: 'Peter' },
{ user: dewey, name: 'Dewey' },
{ user: louie, name: 'Louie' },
]
for (let i = 0; i < 20; i++) {
const member = g1Members[i % g1Members.length]
authenticatedUser = await member.user.toJson()
await mutate({
mutation: CreateMessage,
variables: {
roomId: g1RoomId,
content: faker.lorem.sentence(),
},
})
}
for (const user of additionalUsers.slice(0, 99)) {
authenticatedUser = await jennyRostock.toJson()
const { data: room } = await mutate({
mutation: CreateRoom,
// Group g2 (Yoga Practice) - active members: Bob(owner/creator), Jenny(usual), Dewey(admin), Louie(usual), Dagobert(usual) - Huey is pending
authenticatedUser = await bobDerBaumeister.toJson()
const { data: roomG2 } = await mutate({
mutation: CreateGroupRoom,
variables: { groupId: 'g2' },
})
const g2RoomId = roomG2?.CreateGroupRoom.id
const g2Members = [
{ user: bobDerBaumeister, name: 'Bob' },
{ user: jennyRostock, name: 'Jenny' },
{ user: dewey, name: 'Dewey' },
{ user: louie, name: 'Louie' },
{ user: dagobert, name: 'Dagobert' },
]
for (let i = 0; i < 25; i++) {
const member = g2Members[i % g2Members.length]
authenticatedUser = await member.user.toJson()
await mutate({
mutation: CreateMessage,
variables: {
userId: (await user.toJson()).id,
roomId: g2RoomId,
content: faker.lorem.sentence(),
},
})
for (let i = 0; i < 29; i++) {
authenticatedUser = await jennyRostock.toJson()
await mutate({
mutation: CreateMessage,
variables: {
roomId: room?.CreateRoom.id,
content: faker.lorem.sentence(),
},
})
authenticatedUser = await user.toJson()
await mutate({
mutation: CreateMessage,
variables: {
roomId: room?.CreateRoom.id,
content: faker.lorem.sentence(),
},
})
}
}
// Group g0 (Investigative Journalism) - intentionally NO chat seeded
} catch (err) {
/* eslint-disable-next-line no-console */
console.error(err)

View File

@ -7,7 +7,6 @@ export interface MessageDbProperties {
id: string
indexId: number
saved: boolean
seen: boolean
}
export type Message = Node<Integer, MessageDbProperties>

View File

@ -1,18 +1,15 @@
mutation CreateRoom($userId: ID!) {
CreateRoom(userId: $userId) {
mutation CreateGroupRoom($groupId: ID!) {
CreateGroupRoom(groupId: $groupId) {
id
roomId
roomName
isGroupRoom
lastMessageAt
unreadCount
#avatar
users {
_id
id
name
avatar {
url
}
}
}
}

View File

@ -1,11 +1,14 @@
mutation CreateMessage($roomId: ID!, $content: String!, $files: [FileInput]) {
CreateMessage(roomId: $roomId, content: $content, files: $files) {
mutation CreateMessage($roomId: ID, $userId: ID, $content: String, $files: [FileInput]) {
CreateMessage(roomId: $roomId, userId: $userId, content: $content, files: $files) {
id
content
senderId
username
avatar
date
room {
id
}
saved
distributed
seen

View File

@ -1,5 +1,5 @@
query Message($roomId: ID!, $first: Int, $offset: Int) {
Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) {
query Message($roomId: ID!, $first: Int, $offset: Int, $beforeIndex: Int) {
Message(roomId: $roomId, first: $first, offset: $offset, beforeIndex: $beforeIndex, orderBy: indexId_desc) {
_id
id
indexId

View File

@ -1,5 +1,5 @@
query Room($first: Int, $offset: Int, $id: ID) {
Room(first: $first, offset: $offset, id: $id, orderBy: lastMessageAt_desc) {
query Room($first: Int, $before: String, $id: ID, $userId: ID, $groupId: ID) {
Room(first: $first, before: $before, id: $id, userId: $userId, groupId: $groupId) {
id
roomId
roomName

View File

@ -12,7 +12,6 @@ import { Upload } from '@aws-sdk/lib-storage'
import Factory, { cleanDatabase } from '@db/factories'
import { UserInputError } from '@graphql/errors'
import CreateMessage from '@graphql/queries/messaging/CreateMessage.gql'
import CreateRoom from '@graphql/queries/messaging/CreateRoom.gql'
import { createApolloTestSetup } from '@root/test/helpers'
import { attachments } from './attachments'
@ -94,12 +93,14 @@ describe('delete Attachment', () => {
chatPartner = await u2.toJson()
authenticatedUser = user
const { data: room } = await mutate({
mutation: CreateRoom,
const initResult = await mutate({
mutation: CreateMessage,
variables: {
userId: chatPartner.id,
content: 'init',
},
})
const roomId = initResult.data.CreateMessage.room.id
const f = await Factory.build('file', {
url: 'http://localhost/some/file/url/',
@ -111,7 +112,7 @@ describe('delete Attachment', () => {
const m = await mutate({
mutation: CreateMessage,
variables: {
roomId: room?.CreateRoom.id,
roomId,
content: 'test messsage',
},
})

View File

@ -286,9 +286,14 @@ export default {
RETURN user {.*}, membership {.*}
`
const transactionResponse = await transaction.run(joinGroupCypher, { groupId, userId })
return transactionResponse.records.map((record) => {
const records = transactionResponse.records.map((record) => {
return { user: record.get('user'), membership: record.get('membership') }
})
// Add user to group chat room if they are an active member (not pending)
if (records[0]?.membership?.role && records[0].membership.role !== 'pending') {
await addUserToGroupChatRoom(transaction, groupId, userId)
}
return records
})
if (!result[0]) {
throw new UserInputError('Could not find User or Group')
@ -348,6 +353,12 @@ export default {
const [member] = transactionResponse.records.map((record) => {
return { user: record.get('user'), membership: record.get('membership') }
})
// Manage group chat room membership based on role
if (['usual', 'admin', 'owner'].includes(roleInGroup)) {
await addUserToGroupChatRoom(transaction, groupId, userId)
} else {
await removeUserFromGroupChatRoom(transaction, groupId, userId)
}
return member
})
} finally {
@ -503,6 +514,29 @@ export default {
},
}
const addUserToGroupChatRoom = async (transaction, groupId, userId) => {
await transaction.run(
`
OPTIONAL MATCH (room:Room)-[:ROOM_FOR]->(group:Group {id: $groupId})
WITH room
WHERE room IS NOT NULL
MATCH (user:User {id: $userId})
MERGE (user)-[:CHATS_IN]->(room)
`,
{ groupId, userId },
)
}
const removeUserFromGroupChatRoom = async (transaction, groupId, userId) => {
await transaction.run(
`
OPTIONAL MATCH (user:User {id: $userId})-[chatsIn:CHATS_IN]->(room:Room)-[:ROOM_FOR]->(group:Group {id: $groupId})
DELETE chatsIn
`,
{ groupId, userId },
)
}
const removeUserFromGroupWriteTxResultPromise = async (session, groupId, userId) => {
return session.writeTransaction(async (transaction) => {
const removeUserFromGroupCypher = `
@ -510,7 +544,7 @@ const removeUserFromGroupWriteTxResultPromise = async (session, groupId, userId)
DELETE membership
WITH user, group
OPTIONAL MATCH (author:User)-[:WROTE]->(p:Post)-[:IN]->(group)
WHERE NOT group.groupType = 'public'
WHERE NOT group.groupType = 'public'
AND NOT author.id = $userId
WITH user, collect(p) AS posts
FOREACH (post IN posts |
@ -528,6 +562,8 @@ const removeUserFromGroupWriteTxResultPromise = async (session, groupId, userId)
if (!result) {
throw new UserInputError('User is not a member of this group')
}
// Remove user from group chat room
await removeUserFromGroupChatRoom(transaction, groupId, userId)
return result
})
}

View File

@ -12,12 +12,13 @@ import { Upload } from 'graphql-upload/public/index'
import pubsubContext from '@context/pubsub'
import Factory, { cleanDatabase } from '@db/factories'
import CreateMessage from '@graphql/queries/messaging/CreateMessage.gql'
import CreateRoom from '@graphql/queries/messaging/CreateRoom.gql'
import MarkMessagesAsSeen from '@graphql/queries/messaging/MarkMessagesAsSeen.gql'
import Message from '@graphql/queries/messaging/Message.gql'
import Room from '@graphql/queries/messaging/Room.gql'
import { createApolloTestSetup } from '@root/test/helpers'
import { chatMessageAddedFilter, chatMessageStatusUpdatedFilter } from './messages'
import type { ApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
@ -125,13 +126,14 @@ describe('Message', () => {
describe('room exists', () => {
beforeEach(async () => {
authenticatedUser = await chattingUser.toJson()
const room = await mutate({
mutation: CreateRoom,
const result = await mutate({
mutation: CreateMessage,
variables: {
userId: 'other-chatting-user',
content: 'init',
},
})
roomId = room.data.CreateRoom.id
roomId = result.data.CreateMessage.room.id
})
describe('user chats in room', () => {
@ -202,7 +204,7 @@ describe('Message', () => {
})
describe('unread count for other user', () => {
it('has unread count = 1', async () => {
it('has unread count = 2', async () => {
authenticatedUser = await otherChattingUser.toJson()
await expect(query({ query: Room })).resolves.toMatchObject({
errors: undefined,
@ -210,7 +212,7 @@ describe('Message', () => {
Room: [
expect.objectContaining({
lastMessageAt: expect.any(String),
unreadCount: 1,
unreadCount: 2,
lastMessage: expect.objectContaining({
_id: expect.any(String),
id: expect.any(String),
@ -329,7 +331,7 @@ describe('Message', () => {
).resolves.toMatchObject({
errors: undefined,
data: {
Message: [],
Message: [expect.objectContaining({ content: 'init' })],
},
})
})
@ -407,13 +409,14 @@ describe('Message', () => {
describe('room exists with authenticated user chatting', () => {
beforeEach(async () => {
authenticatedUser = await chattingUser.toJson()
const room = await mutate({
mutation: CreateRoom,
const result = await mutate({
mutation: CreateMessage,
variables: {
userId: 'other-chatting-user',
content: 'init',
},
})
roomId = room.data.CreateRoom.id
roomId = result.data.CreateMessage.room.id
await mutate({
mutation: CreateMessage,
@ -435,10 +438,15 @@ describe('Message', () => {
errors: undefined,
data: {
Message: [
expect.objectContaining({
indexId: 0,
content: 'init',
senderId: 'chatting-user',
}),
{
id: expect.any(String),
_id: result.data?.Message[0].id,
indexId: 0,
_id: result.data?.Message[1].id,
indexId: 1,
content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
@ -486,8 +494,13 @@ describe('Message', () => {
data: {
Message: [
expect.objectContaining({
id: expect.any(String),
indexId: 0,
content: 'init',
senderId: 'chatting-user',
}),
expect.objectContaining({
id: expect.any(String),
indexId: 1,
content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
@ -499,7 +512,7 @@ describe('Message', () => {
}),
expect.objectContaining({
id: expect.any(String),
indexId: 1,
indexId: 2,
content: 'A nice response message to chatting user',
senderId: 'other-chatting-user',
username: 'Other Chatting User',
@ -511,7 +524,7 @@ describe('Message', () => {
}),
expect.objectContaining({
id: expect.any(String),
indexId: 2,
indexId: 3,
content: 'And another nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
@ -527,6 +540,8 @@ describe('Message', () => {
})
it('returns the messages paginated', async () => {
// Messages ordered by indexId DESC: 3, 2, 1, 0
// first: 2, offset: 0 → indexId 2 and 3 (reversed to ASC)
await expect(
query({
query: Message,
@ -541,27 +556,20 @@ describe('Message', () => {
data: {
Message: [
expect.objectContaining({
id: expect.any(String),
indexId: 1,
indexId: 2,
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,
indexId: 3,
content: 'And another nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
}),
],
},
})
// first: 2, offset: 2 → indexId 0 and 1 (reversed to ASC)
await expect(
query({
query: Message,
@ -576,13 +584,14 @@ describe('Message', () => {
data: {
Message: [
expect.objectContaining({
id: expect.any(String),
indexId: 0,
content: 'init',
senderId: 'chatting-user',
}),
expect.objectContaining({
indexId: 1,
content: 'Some nice message to other chatting user',
senderId: 'chatting-user',
username: 'Chatting User',
avatar: expect.any(String),
date: expect.any(String),
}),
],
},
@ -639,13 +648,14 @@ describe('Message', () => {
const messageIds: string[] = []
beforeEach(async () => {
authenticatedUser = await chattingUser.toJson()
const room = await mutate({
mutation: CreateRoom,
const result = await mutate({
mutation: CreateMessage,
variables: {
userId: 'other-chatting-user',
content: 'init',
},
})
roomId = room.data.CreateRoom.id
roomId = result.data.CreateMessage.room.id
await mutate({
mutation: CreateMessage,
variables: {
@ -712,6 +722,7 @@ describe('Message', () => {
).resolves.toMatchObject({
data: {
Message: [
expect.objectContaining({ seen: true }),
expect.objectContaining({ seen: true }),
expect.objectContaining({ seen: false }),
expect.objectContaining({ seen: true }),
@ -721,4 +732,124 @@ describe('Message', () => {
})
})
})
describe('message query with beforeIndex', () => {
let testRoomId: string
beforeAll(async () => {
authenticatedUser = await chattingUser.toJson()
const result = await mutate({
mutation: CreateMessage,
variables: { userId: 'other-chatting-user', content: 'msg-0' },
})
testRoomId = result.data.CreateMessage.room.id
await mutate({ mutation: CreateMessage, variables: { roomId: testRoomId, content: 'msg-1' } })
await mutate({ mutation: CreateMessage, variables: { roomId: testRoomId, content: 'msg-2' } })
})
it('returns only messages with indexId less than beforeIndex', async () => {
const result = await query({
query: Message,
variables: { roomId: testRoomId, beforeIndex: 2 },
})
expect(result.errors).toBeUndefined()
const indexIds: number[] = result.data.Message.map((m: { indexId: number }) => m.indexId)
expect(indexIds.every((id: number) => id < 2)).toBe(true)
})
})
describe('subscription filters', () => {
describe('chatMessageAddedFilter', () => {
it('returns true for recipient and marks as distributed', async () => {
const mockSession = {
writeTransaction: jest
.fn()
.mockResolvedValue([{ roomId: 'r1', authorId: 'a1', messageIds: ['m1'] }]),
close: jest.fn(),
}
const filterContext = {
user: { id: 'recipient' },
driver: { session: () => mockSession },
pubsub: { publish: jest.fn() },
}
const result = await chatMessageAddedFilter(
{ userId: 'recipient', chatMessageAdded: { id: 'm1' } },
filterContext,
)
expect(result).toBe(true)
expect(mockSession.writeTransaction).toHaveBeenCalled()
expect(filterContext.pubsub.publish).toHaveBeenCalledWith(
'CHAT_MESSAGE_STATUS_UPDATED',
expect.objectContaining({
chatMessageStatusUpdated: { roomId: 'r1', messageIds: ['m1'], status: 'distributed' },
}),
)
})
it('returns false for non-recipient', async () => {
const result = await chatMessageAddedFilter(
{ userId: 'other', chatMessageAdded: { id: 'm1' } },
{ user: { id: 'me' } },
)
expect(result).toBe(false)
})
it('skips distributed marking when no message id', async () => {
const mockSession = { writeTransaction: jest.fn(), close: jest.fn() }
const result = await chatMessageAddedFilter(
{ userId: 'me', chatMessageAdded: {} },
{ user: { id: 'me' }, driver: { session: () => mockSession } },
)
expect(result).toBe(true)
expect(mockSession.writeTransaction).not.toHaveBeenCalled()
})
})
describe('chatMessageStatusUpdatedFilter', () => {
it('returns true when authorId matches', () => {
expect(chatMessageStatusUpdatedFilter({ authorId: 'u1' }, { user: { id: 'u1' } })).toBe(
true,
)
})
it('returns false when authorId does not match', () => {
expect(chatMessageStatusUpdatedFilter({ authorId: 'u1' }, { user: { id: 'u2' } })).toBe(
false,
)
})
})
})
describe('create message validation', () => {
beforeAll(async () => {
authenticatedUser = await chattingUser.toJson()
})
it('rejects creating a room with self', async () => {
const result = await mutate({
mutation: CreateMessage,
variables: { userId: 'chatting-user', content: 'test' },
})
expect(result.errors).toBeDefined()
expect(result.errors?.[0].message).toContain('Cannot create a room with self')
})
it('rejects missing roomId and userId', async () => {
const result = await mutate({
mutation: CreateMessage,
variables: { content: 'test' },
})
expect(result.errors).toBeDefined()
expect(result.errors?.[0].message).toContain('Either roomId or userId must be provided')
})
it('rejects empty content without files', async () => {
const result = await mutate({
mutation: CreateMessage,
variables: { userId: 'other-chatting-user', content: '' },
})
expect(result.errors).toBeDefined()
expect(result.errors?.[0].message).toContain('Message must have content or files')
})
})
})

View File

@ -9,7 +9,7 @@ import { withFilter } from 'graphql-subscriptions'
import { neo4jgraphql } from 'neo4j-graphql-js'
import CONFIG from '@config/index'
import { CHAT_MESSAGE_ADDED } from '@constants/subscriptions'
import { CHAT_MESSAGE_ADDED, CHAT_MESSAGE_STATUS_UPDATED } from '@constants/subscriptions'
import { attachments } from './attachments/attachments'
import Resolver from './helpers/Resolver'
@ -21,31 +21,65 @@ const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
const setDistributedCypher = `
MATCH (m:Message) WHERE m.id IN $undistributedMessagesIds
SET m.distributed = true
RETURN m { .* }
WITH m
MATCH (m)-[:INSIDE]->(room:Room)
MATCH (m)<-[:CREATED]-(author:User)
RETURN DISTINCT room.id AS roomId, author.id AS authorId, collect(m.id) AS messageIds
`
const setDistributedTxResponse = await transaction.run(setDistributedCypher, {
const result = await transaction.run(setDistributedCypher, {
undistributedMessagesIds,
})
const messages = await setDistributedTxResponse.records.map((record) => record.get('m'))
return messages
return result.records.map((record) => ({
roomId: record.get('roomId'),
authorId: record.get('authorId'),
messageIds: record.get('messageIds'),
}))
})
}
export const chatMessageAddedFilter = async (payload, context) => {
const isRecipient = payload.userId === context.user?.id
if (isRecipient && payload.chatMessageAdded?.id) {
const session = context.driver.session()
try {
const results = await setMessagesAsDistributed([payload.chatMessageAdded.id], session)
for (const { roomId, authorId, messageIds } of results) {
void context.pubsub.publish(CHAT_MESSAGE_STATUS_UPDATED, {
authorId,
chatMessageStatusUpdated: { roomId, messageIds, status: 'distributed' },
})
}
} finally {
await session.close()
}
}
return isRecipient
}
export const chatMessageStatusUpdatedFilter = (payload, context) => {
return payload.authorId === context.user?.id
}
export default {
Subscription: {
chatMessageAdded: {
subscribe: withFilter(
(_, __, context) => context.pubsub.asyncIterator(CHAT_MESSAGE_ADDED),
(payload, variables, context) => {
return payload.userId === context.user?.id
},
async (payload, variables, context) => chatMessageAddedFilter(payload, context),
),
},
chatMessageStatusUpdated: {
subscribe: withFilter(
(_, __, context) => context.pubsub.asyncIterator(CHAT_MESSAGE_STATUS_UPDATED),
(payload, variables, context) => chatMessageStatusUpdatedFilter(payload, context),
),
},
},
Query: {
Message: async (object, params, context, resolveInfo) => {
const { roomId } = params
const { roomId, beforeIndex } = params
delete params.roomId
delete params.beforeIndex
if (!params.filter) params.filter = {}
params.filter.room = {
id: roomId,
@ -53,73 +87,128 @@ export default {
id: context.user.id,
},
}
if (beforeIndex !== undefined && beforeIndex !== null) {
params.filter.indexId_lt = beforeIndex
}
const resolved = await neo4jgraphql(object, params, context, resolveInfo)
if (resolved) {
// Mark undistributed messages as distributed (fallback for missed socket deliveries)
const undistributedMessagesIds = resolved
.filter((msg) => !msg.distributed && msg.senderId !== context.user.id)
.map((msg) => msg.id)
const session = context.driver.session()
try {
if (undistributedMessagesIds.length > 0) {
await setMessagesAsDistributed(undistributedMessagesIds, session)
if (undistributedMessagesIds.length > 0) {
const session = context.driver.session()
try {
const results = await setMessagesAsDistributed(undistributedMessagesIds, session)
for (const { roomId: msgRoomId, authorId, messageIds } of results) {
void context.pubsub.publish(CHAT_MESSAGE_STATUS_UPDATED, {
authorId,
chatMessageStatusUpdated: { roomId: msgRoomId, messageIds, status: 'distributed' },
})
}
} finally {
await session.close()
}
} finally {
await session.close()
}
// send subscription to author to updated the messages
}
return resolved.reverse()
return (resolved || []).reverse()
},
},
Mutation: {
CreateMessage: async (_parent, params, context, _resolveInfo) => {
const { roomId, content, files = [] } = params
const { roomId, userId, content, files = [] } = params
const {
user: { id: currentUserId },
} = context
if (userId && userId === currentUserId) {
throw new Error('Cannot create a room with self')
}
if (!roomId && !userId) {
throw new Error('Either roomId or userId must be provided')
}
if (!content?.trim() && files.length === 0) {
throw new Error('Message must have content or files')
}
const session = context.driver.session()
try {
return await session.writeTransaction(async (transaction) => {
// If userId is provided, find-or-create a DM room first
if (userId) {
await transaction.run(
`
MATCH (currentUser:User { id: $currentUserId })
MATCH (user:User { id: $userId })
OPTIONAL MATCH (currentUser)-[:CHATS_IN]->(existingRoom:Room)<-[:CHATS_IN]-(user)
WHERE NOT (existingRoom)-[:ROOM_FOR]->(:Group)
WITH currentUser, user, collect(existingRoom)[0] AS existingRoom
WITH currentUser, user, existingRoom
WHERE existingRoom IS NULL
CREATE (currentUser)-[:CHATS_IN]->(:Room {
createdAt: toString(datetime()),
id: apoc.create.uuid()
})<-[:CHATS_IN]-(user)
`,
{ currentUserId, userId },
)
}
// Resolve the room — either by roomId or by finding the DM room with userId
const matchRoom = roomId
? `MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId })`
: `MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room)<-[:CHATS_IN]-(user:User { id: $userId })
WHERE NOT (room)-[:ROOM_FOR]->(:Group)`
const createMessageCypher = `
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId })
${matchRoom}
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
OPTIONAL MATCH (existing:Message)-[:INSIDE]->(room)
WITH room, currentUser, image, MAX(existing.indexId) AS maxIndex
SET room.messageCounter = CASE
WHEN room.messageCounter IS NOT NULL THEN room.messageCounter + 1
WHEN maxIndex IS NOT NULL THEN maxIndex + 2
ELSE 1
END,
room.lastMessageAt = toString(datetime())
WITH room, currentUser, image
CREATE (currentUser)-[:CREATED]->(message:Message {
createdAt: toString(datetime()),
id: apoc.create.uuid(),
indexId: CASE WHEN maxIndex IS NOT NULL THEN maxIndex + 1 ELSE 0 END,
indexId: room.messageCounter - 1,
content: LEFT($content,2000),
saved: true,
distributed: false,
seen: false
distributed: false
})-[:INSIDE]->(room)
SET room.lastMessageAt = toString(datetime())
WITH message, currentUser, image, room
OPTIONAL MATCH (room)<-[:CHATS_IN]-(recipient:User)
WHERE NOT recipient.id = $currentUserId
WITH message, currentUser, image, collect(recipient) AS recipients
FOREACH (r IN recipients | CREATE (r)-[:HAS_NOT_SEEN]->(message))
RETURN message {
.*,
indexId: toString(message.indexId),
recipientId: recipientUser.id,
senderId: currentUser.id,
username: currentUser.name,
avatar: image.url,
date: message.createdAt
date: message.createdAt,
seen: false
}
`
const createMessageTxResponse = await transaction.run(createMessageCypher, {
const txResponse = await transaction.run(createMessageCypher, {
currentUserId,
roomId,
userId,
content,
})
const [message] = createMessageTxResponse.records.map((record) => record.get('message'))
const [message] = txResponse.records.map((record) => record.get('message'))
// this is the case if the room doesn't exist - requires refactoring for implicit rooms
if (!message) {
return null
}
@ -150,20 +239,30 @@ export default {
const currentUserId = context.user.id
const session = context.driver.session()
try {
await session.writeTransaction(async (transaction) => {
const setSeenCypher = `
MATCH (m:Message)<-[:CREATED]-(user:User)
WHERE m.id IN $messageIds AND NOT user.id = $currentUserId
SET m.seen = true
RETURN m { .* }
const result = await session.writeTransaction(async (transaction) => {
const cypher = `
MATCH (user:User { id: $currentUserId })-[r:HAS_NOT_SEEN]->(m:Message)
WHERE m.id IN $messageIds
DELETE r
WITH m
MATCH (m)-[:INSIDE]->(room:Room)
MATCH (m)<-[:CREATED]-(author:User)
RETURN DISTINCT room.id AS roomId, author.id AS authorId
`
const setSeenTxResponse = await transaction.run(setSeenCypher, {
return transaction.run(cypher, {
messageIds,
currentUserId,
})
return setSeenTxResponse.records.map((record) => record.get('m'))
})
// send subscription to author to updated the messages
// Notify message authors that their messages have been seen
for (const record of result.records) {
const roomId = record.get('roomId')
const authorId = record.get('authorId')
void context.pubsub.publish(CHAT_MESSAGE_STATUS_UPDATED, {
authorId,
chatMessageStatusUpdated: { roomId, messageIds, status: 'seen' },
})
}
return true
} finally {
await session.close()

View File

@ -3,12 +3,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-explicit-any */
import Factory, { cleanDatabase } from '@db/factories'
import CreateGroupRoom from '@graphql/queries/messaging/CreateGroupRoom.gql'
import CreateMessage from '@graphql/queries/messaging/CreateMessage.gql'
import CreateRoom from '@graphql/queries/messaging/CreateRoom.gql'
import Room from '@graphql/queries/messaging/Room.gql'
import UnreadRooms from '@graphql/queries/messaging/UnreadRooms.gql'
import { createApolloTestSetup } from '@root/test/helpers'
import { roomCountUpdatedFilter } from './rooms'
import type { ApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
@ -64,7 +66,7 @@ describe('Room', () => {
])
})
describe('create room', () => {
describe('create room via CreateMessage with userId', () => {
describe('unauthenticated', () => {
beforeAll(() => {
authenticatedUser = null
@ -73,9 +75,10 @@ describe('Room', () => {
it('throws authorization error', async () => {
await expect(
mutate({
mutation: CreateRoom,
mutation: CreateMessage,
variables: {
userId: 'some-id',
content: 'init',
},
}),
).resolves.toMatchObject({
@ -93,15 +96,16 @@ describe('Room', () => {
it('returns null', async () => {
await expect(
mutate({
mutation: CreateRoom,
mutation: CreateMessage,
variables: {
userId: 'not-existing-user',
content: 'init',
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
CreateRoom: null,
CreateMessage: null,
},
})
})
@ -111,9 +115,10 @@ describe('Room', () => {
it('throws error', async () => {
await expect(
mutate({
mutation: CreateRoom,
mutation: CreateMessage,
variables: {
userId: 'chatting-user',
content: 'init',
},
}),
).resolves.toMatchObject({
@ -123,60 +128,46 @@ describe('Room', () => {
})
describe('user id exists', () => {
it('returns the id of the room', async () => {
it('creates a room and returns the message with room id', async () => {
const result = await mutate({
mutation: CreateRoom,
mutation: CreateMessage,
variables: {
userId: 'other-chatting-user',
content: 'init',
},
})
roomId = result.data.CreateRoom.id
roomId = result.data.CreateMessage.room.id
expect(result).toMatchObject({
errors: undefined,
data: {
CreateRoom: {
CreateMessage: {
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),
},
},
]),
content: 'init',
room: {
id: expect.any(String),
},
},
},
})
})
})
describe('create room with same user id', () => {
it('returns the id of the room', async () => {
await expect(
mutate({
mutation: CreateRoom,
variables: {
userId: 'other-chatting-user',
},
}),
).resolves.toMatchObject({
describe('send message to same user id again', () => {
it('returns the same room id', async () => {
const result = await mutate({
mutation: CreateMessage,
variables: {
userId: 'other-chatting-user',
content: 'another message',
},
})
expect(result).toMatchObject({
errors: undefined,
data: {
CreateRoom: {
id: roomId,
CreateMessage: {
room: {
id: roomId,
},
},
},
})
@ -254,7 +245,7 @@ describe('Room', () => {
id: expect.any(String),
roomId: result.data.Room[0].id,
roomName: 'Chatting User',
unreadCount: 0,
unreadCount: 2,
users: expect.arrayContaining([
{
_id: 'chatting-user',
@ -312,21 +303,12 @@ describe('Room', () => {
})
describe('authenticated', () => {
let otherRoomId: string
beforeAll(async () => {
authenticatedUser = await chattingUser.toJson()
const result = await mutate({
mutation: CreateRoom,
variables: {
userId: 'not-chatting-user',
},
})
otherRoomId = result.data.CreateRoom.roomId
await mutate({
mutation: CreateMessage,
variables: {
roomId: otherRoomId,
userId: 'not-chatting-user',
content: 'Message to not chatting user',
},
})
@ -345,17 +327,10 @@ describe('Room', () => {
},
})
authenticatedUser = await otherChattingUser.toJson()
const result2 = await mutate({
mutation: CreateRoom,
variables: {
userId: 'not-chatting-user',
},
})
otherRoomId = result2.data.CreateRoom.roomId
await mutate({
mutation: CreateMessage,
variables: {
roomId: otherRoomId,
userId: 'not-chatting-user',
content: 'Other message to not chatting user',
},
})
@ -440,23 +415,23 @@ describe('Room', () => {
beforeAll(async () => {
authenticatedUser = await chattingUser.toJson()
await mutate({
mutation: CreateRoom,
mutation: CreateMessage,
variables: {
userId: 'second-chatting-user',
content: 'init',
},
})
await mutate({
mutation: CreateRoom,
mutation: CreateMessage,
variables: {
userId: 'third-chatting-user',
content: 'init',
},
})
})
it('returns the rooms paginated', async () => {
await expect(
query({ query: Room, variables: { first: 3, offset: 0 } }),
).resolves.toMatchObject({
await expect(query({ query: Room, variables: { first: 3 } })).resolves.toMatchObject({
errors: undefined,
data: {
Room: expect.arrayContaining([
@ -464,9 +439,12 @@ describe('Room', () => {
id: expect.any(String),
roomId: expect.any(String),
roomName: 'Third Chatting User',
lastMessageAt: null,
lastMessageAt: expect.any(String),
unreadCount: 0,
lastMessage: null,
lastMessage: expect.objectContaining({
content: 'init',
senderId: 'chatting-user',
}),
users: expect.arrayContaining([
expect.objectContaining({
_id: 'chatting-user',
@ -490,9 +468,12 @@ describe('Room', () => {
id: expect.any(String),
roomId: expect.any(String),
roomName: 'Second Chatting User',
lastMessageAt: null,
lastMessageAt: expect.any(String),
unreadCount: 0,
lastMessage: null,
lastMessage: expect.objectContaining({
content: 'init',
senderId: 'chatting-user',
}),
users: expect.arrayContaining([
expect.objectContaining({
_id: 'chatting-user',
@ -552,38 +533,7 @@ describe('Room', () => {
]),
},
})
await expect(
query({ query: Room, 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),
},
},
]),
}),
],
},
})
// Note: offset-based pagination removed in favor of cursor-based (before parameter)
})
})
@ -639,4 +589,160 @@ describe('Room', () => {
})
})
})
describe('query room by userId', () => {
beforeAll(async () => {
authenticatedUser = await chattingUser.toJson()
})
it('returns the DM room with the specified user', async () => {
const result = await query({
query: Room,
variables: { userId: 'other-chatting-user' },
})
expect(result).toMatchObject({
errors: undefined,
data: {
Room: [
expect.objectContaining({
roomName: 'Other Chatting User',
users: expect.arrayContaining([
expect.objectContaining({ id: 'chatting-user' }),
expect.objectContaining({ id: 'other-chatting-user' }),
]),
}),
],
},
})
})
it('returns empty when no DM room exists', async () => {
const result = await query({
query: Room,
variables: { userId: 'non-existent-user' },
})
expect(result).toMatchObject({
errors: undefined,
data: {
Room: [],
},
})
})
})
describe('query room by groupId', () => {
let groupRoomId: string
beforeAll(async () => {
await Factory.build(
'group',
{
id: 'test-group',
name: 'Test Group',
},
{ owner: chattingUser },
)
// Add other user as member
const session = database.driver.session()
try {
await session.writeTransaction((txc) =>
txc.run(
`MATCH (u:User {id: 'other-chatting-user'}), (g:Group {id: 'test-group'})
MERGE (u)-[m:MEMBER_OF]->(g) SET m.role = 'usual', m.createdAt = toString(datetime())`,
),
)
} finally {
await session.close()
}
authenticatedUser = await chattingUser.toJson()
})
describe('CreateGroupRoom', () => {
it('creates a group room', async () => {
const result = await mutate({
mutation: CreateGroupRoom,
variables: { groupId: 'test-group' },
})
expect(result).toMatchObject({
errors: undefined,
data: {
CreateGroupRoom: expect.objectContaining({
roomName: 'Test Group',
isGroupRoom: true,
users: expect.arrayContaining([
expect.objectContaining({ id: 'chatting-user' }),
expect.objectContaining({ id: 'other-chatting-user' }),
]),
}),
},
})
groupRoomId = result.data.CreateGroupRoom.id
})
it('returns existing room on second call', async () => {
const result = await mutate({
mutation: CreateGroupRoom,
variables: { groupId: 'test-group' },
})
expect(result.data.CreateGroupRoom.id).toBe(groupRoomId)
})
it('fails for non-member', async () => {
authenticatedUser = await notChattingUser.toJson()
const result = await mutate({
mutation: CreateGroupRoom,
variables: { groupId: 'test-group' },
})
expect(result.errors).toBeDefined()
authenticatedUser = await chattingUser.toJson()
})
})
describe('query by groupId', () => {
it('returns the group room', async () => {
const result = await query({
query: Room,
variables: { groupId: 'test-group' },
})
expect(result).toMatchObject({
errors: undefined,
data: {
Room: [
expect.objectContaining({
id: groupRoomId,
roomName: 'Test Group',
}),
],
},
})
})
it('returns empty for non-existent group', async () => {
const result = await query({
query: Room,
variables: { groupId: 'non-existent' },
})
expect(result).toMatchObject({
errors: undefined,
data: {
Room: [],
},
})
})
})
})
})
describe('roomCountUpdatedFilter', () => {
it('returns true when payload userId matches context user', () => {
expect(roomCountUpdatedFilter({ userId: 'u1' }, {}, { user: { id: 'u1' } })).toBe(true)
})
it('returns false when userId does not match', () => {
expect(roomCountUpdatedFilter({ userId: 'u1' }, {}, { user: { id: 'u2' } })).toBe(false)
})
it('returns false when context user is null', () => {
expect(roomCountUpdatedFilter({ userId: 'u1' }, {}, { user: null })).toBe(false)
})
})

View File

@ -15,10 +15,11 @@ import Resolver from './helpers/Resolver'
export const getUnreadRoomsCount = async (userId, session) => {
return session.readTransaction(async (transaction) => {
const unreadRoomsCypher = `
MATCH (user:User { id: $userId })-[:CHATS_IN]->(room:Room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User)
WHERE NOT sender.id = $userId AND NOT message.seen
AND NOT (user)-[:BLOCKED]->(sender)
AND NOT (user)-[:MUTED]->(sender)
MATCH (user:User { id: $userId })-[:HAS_NOT_SEEN]->(message:Message)-[:INSIDE]->(room:Room)<-[:CHATS_IN]-(user)
OPTIONAL MATCH (message)<-[:CREATED]-(sender:User)
WHERE (user)-[:BLOCKED]->(sender) OR (user)-[:MUTED]->(sender)
WITH room, message, sender
WHERE sender IS NULL
RETURN toString(COUNT(DISTINCT room)) AS count
`
const unreadRoomsTxResponse = await transaction.run(unreadRoomsCypher, { userId })
@ -26,24 +27,101 @@ export const getUnreadRoomsCount = async (userId, session) => {
})
}
export const roomCountUpdatedFilter = (payload, variables, context) => {
return payload.userId === context.user?.id
}
export default {
Subscription: {
roomCountUpdated: {
subscribe: withFilter(
(_, __, context) => context.pubsub.asyncIterator(ROOM_COUNT_UPDATED),
(payload, variables, context) => {
return payload.userId === context.user?.id
},
roomCountUpdatedFilter,
),
},
},
Query: {
Room: async (object, params, context, resolveInfo) => {
if (!params.filter) params.filter = {}
params.filter.users_some = {
id: context.user.id,
// Single room lookup by userId or groupId
if (params.userId || params.groupId) {
const session = context.driver.session()
try {
const cypher = params.groupId
? `
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room)-[:ROOM_FOR]->(group:Group { id: $groupId })
RETURN room { .* } AS room
`
: `
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room)<-[:CHATS_IN]-(user:User { id: $userId })
WHERE NOT (room)-[:ROOM_FOR]->(:Group)
RETURN room { .* } AS room
`
const result = await session.readTransaction(async (transaction) => {
return transaction.run(cypher, {
currentUserId: context.user.id,
userId: params.userId || null,
groupId: params.groupId || null,
})
})
const rooms = result.records.map((record) => record.get('room'))
if (rooms.length === 0) return []
// Re-query via neo4jgraphql to get all computed fields
delete params.userId
delete params.groupId
params.filter = { users_some: { id: context.user.id } }
params.id = rooms[0].id
return neo4jgraphql(object, params, context, resolveInfo)
} finally {
await session.close()
}
}
// Single room lookup by id
if (params.id) {
if (!params.filter) params.filter = {}
params.filter.users_some = { id: context.user.id }
return neo4jgraphql(object, params, context, resolveInfo)
}
// Room list with cursor-based pagination sorted by latest activity
const session = context.driver.session()
try {
const first = params.first || 10
const before = params.before || null
const result = await session.readTransaction(async (transaction) => {
const cypher = `
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room)
WITH room, COALESCE(room.lastMessageAt, room.createdAt) AS sortDate
${before ? 'WHERE sortDate < $before' : ''}
RETURN room.id AS id
ORDER BY sortDate DESC
LIMIT toInteger($first)
`
return transaction.run(cypher, {
currentUserId: context.user.id,
first,
before,
})
})
const roomIds: string[] = result.records.map((record) => record.get('id') as string)
if (roomIds.length === 0) return []
// Batch query via neo4jgraphql with id_in filter (avoids N+1)
const roomParams = {
filter: {
id_in: roomIds,
users_some: { id: context.user.id },
},
}
const rooms = await neo4jgraphql(object, roomParams, context, resolveInfo)
// Preserve the sort order from the cursor query
const orderMap = new Map<string, number>(roomIds.map((id, i) => [id, i]))
return (rooms || []).sort(
(a: { id: string }, b: { id: string }) =>
(orderMap.get(a.id) || 0) - (orderMap.get(b.id) || 0),
)
} finally {
await session.close()
}
return neo4jgraphql(object, params, context, resolveInfo)
},
UnreadRooms: async (_object, _params, context, _resolveInfo) => {
const {
@ -59,46 +137,51 @@ export default {
},
},
Mutation: {
CreateRoom: async (_parent, params, context, _resolveInfo) => {
const { userId } = params
CreateGroupRoom: async (_parent, params, context, _resolveInfo) => {
const { groupId } = params
const {
user: { id: currentUserId },
} = context
if (userId === currentUserId) {
throw new Error('Cannot create a room with self')
}
const session = context.driver.session()
try {
const room = await session.writeTransaction(async (transaction) => {
const createRoomCypher = `
MATCH (currentUser:User { id: $currentUserId })
MATCH (user:User { id: $userId })
MERGE (currentUser)-[:CHATS_IN]->(room:Room)<-[:CHATS_IN]-(user)
// Step 1: Create/merge the room and add all active group members to it
const createGroupRoomCypher = `
MATCH (currentUser:User { id: $currentUserId })-[membership:MEMBER_OF]->(group:Group { id: $groupId })
WHERE membership.role IN ['usual', 'admin', 'owner']
MERGE (room:Room)-[:ROOM_FOR]->(group)
ON CREATE SET
room.createdAt = toString(datetime()),
room.id = apoc.create.uuid()
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
WITH room, group, currentUser
MATCH (member:User)-[m:MEMBER_OF]->(group)
WHERE m.role IN ['usual', 'admin', 'owner']
MERGE (member)-[:CHATS_IN]->(room)
WITH room, group, currentUser, collect(properties(member)) AS members
OPTIONAL MATCH (currentUser)-[:HAS_NOT_SEEN]->(message:Message)-[:INSIDE]->(room)
WITH room, group, members, COUNT(DISTINCT message) AS unread
OPTIONAL MATCH (group)-[:AVATAR_IMAGE]->(groupImg:Image)
RETURN room {
.*,
users: [properties(currentUser), properties(user)],
roomName: roomName,
unreadCount: toString(COUNT(DISTINCT message))
roomName: group.name,
avatar: groupImg.url,
isGroupRoom: true,
group: properties(group),
users: members,
unreadCount: toString(unread)
}
`
const createRoomTxResponse = await transaction.run(createRoomCypher, {
userId,
const createGroupRoomTxResponse = await transaction.run(createGroupRoomCypher, {
groupId,
currentUserId,
})
const [room] = createRoomTxResponse.records.map((record) => record.get('room'))
const [room] = createGroupRoomTxResponse.records.map((record) => record.get('room'))
return room
})
if (room) {
room.roomId = room.id
if (!room) {
throw new Error('Could not create group room. User may not be a member of the group.')
}
room.roomId = room.id
return room
} finally {
await session.close()
@ -111,6 +194,9 @@ export default {
hasMany: {
users: '<-[:CHATS_IN]-(related:User)',
},
hasOne: {
group: '-[:ROOM_FOR]->(related:Group)',
},
}),
},
}

View File

@ -4,6 +4,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import { queryString } from './searches/queryString'
@ -15,9 +16,9 @@ const cypherTemplate = (setup) => `
${setup.match}
${setup.whereClause}
${setup.withClause}
RETURN
RETURN
${setup.returnClause}
AS result
AS result${setup.returnScore === false ? '' : ', score'}
SKIP toInteger($skip)
${setup.limit}
`
@ -37,9 +38,9 @@ const searchPostsSetup = {
MATCH (user:User {id: $userId})
OPTIONAL MATCH (user)-[block:MUTED]->(author)
OPTIONAL MATCH (user)-[restriction:CANNOT_SEE]->(resource)
WITH user, resource, author, block, restriction`,
WITH user, resource, author, block, restriction, score`,
whereClause: postWhereClause,
withClause: `WITH resource, author,
withClause: `WITH resource, author, score,
[(resource)<-[:COMMENTS]-(comment:Comment) | comment] AS comments,
[(resource)<-[:SHOUTED]-(user:User) | user] AS shouter`,
returnClause: `resource {
@ -77,18 +78,32 @@ const searchGroupsSetup = {
match: `MATCH (resource:Group)
MATCH (user:User {id: $userId})
OPTIONAL MATCH (user)-[membership:MEMBER_OF]->(resource)
WITH user, resource, membership`,
WITH user, resource, membership, score`,
whereClause: `WHERE score >= 0.0
AND NOT (resource.deleted = true OR resource.disabled = true)
AND (resource.groupType IN ['public', 'closed']
OR membership.role IN ['usual', 'admin', 'owner'])`,
withClause: 'WITH resource, membership',
withClause: 'WITH resource, membership, score',
returnClause: `resource { .*, myRole: membership.role, __typename: 'Group' }`,
limit: 'LIMIT toInteger($limit)',
}
const searchMyGroupsSetup = {
fulltextIndex: 'group_fulltext_search',
match: `MATCH (resource:Group)
MATCH (user:User {id: $userId})-[membership:MEMBER_OF]->(resource)
WITH user, resource, membership, score`,
whereClause: `WHERE score >= 0.0
AND NOT (resource.deleted = true OR resource.disabled = true)
AND membership.role IN ['usual', 'admin', 'owner']`,
withClause: 'WITH resource, membership, score',
returnClause: `resource { .*, myRole: membership.role, __typename: 'Group' }`,
limit: 'LIMIT toInteger($limit)',
}
const countSetup = {
returnClause: 'toString(size(collect(resource)))',
returnScore: false,
limit: '',
}
@ -116,7 +131,10 @@ const searchResultPromise = async (session, setup, params) => {
}
const searchResultCallback = (result) => {
const response = result.records.map((r) => r.get('result'))
const response = result.records.map((r) => ({
...r.get('result'),
_score: r.has('score') ? r.get('score') : 0,
}))
if (Array.isArray(response) && response.length && response[0].__typename === 'Post') {
response.forEach((post) => {
post.postType = [post.postType]
@ -232,6 +250,23 @@ export default {
}),
}
},
searchChatTargets: async (_parent, args, context, _resolveInfo) => {
const { query } = args
const limit = Math.max(1, Math.min(Number(args.limit) || 10, 50))
const userId = context.user?.id || null
const params = {
query: queryString(query),
skip: 0,
limit,
userId,
}
const results = [
...(await getSearchResults(context, searchUsersSetup, params)),
...(await getSearchResults(context, searchMyGroupsSetup, params)),
]
results.sort((a, b) => (b._score || 0) - (a._score || 0))
return results.slice(0, limit)
},
searchResults: async (_parent, args, context, _resolveInfo) => {
const { query, limit } = args
const userId = context.user?.id || null

View File

@ -28,19 +28,37 @@ type Message {
saved: Boolean
distributed: Boolean
seen: Boolean
@cypher(
statement: """
MATCH (this)<-[:CREATED]-(author:User)
OPTIONAL MATCH (unseer:User)-[:HAS_NOT_SEEN]->(this)
WHERE CASE
WHEN author.id = $cypherParams.currentUserId THEN true
ELSE unseer.id = $cypherParams.currentUserId
END
RETURN count(unseer) = 0
"""
)
files: [File]! @relation(name: "ATTACHMENT", direction: "OUT")
}
type Mutation {
CreateMessage(roomId: ID!, content: String, files: [FileInput]): Message
CreateMessage(roomId: ID, userId: ID, content: String, files: [FileInput]): Message
MarkMessagesAsSeen(messageIds: [String!]): Boolean
}
type Query {
Message(roomId: ID!, first: Int, offset: Int, orderBy: [_MessageOrdering]): [Message]
Message(roomId: ID!, first: Int, offset: Int, beforeIndex: Int, orderBy: [_MessageOrdering]): [Message]
}
type ChatMessageStatusPayload {
roomId: ID!
messageIds: [String!]!
status: String!
}
type Subscription {
chatMessageAdded: Message
chatMessageStatusUpdated: ChatMessageStatusPayload
}

View File

@ -1,12 +1,3 @@
# input _RoomFilter {
# AND: [_RoomFilter!]
# OR: [_RoomFilter!]
# id: ID
# users_some: _UserFilter
# }
# TODO change this to last message date
enum _RoomOrdering {
lastMessageAt_desc
createdAt_desc
@ -18,19 +9,36 @@ type Room {
updatedAt: String
users: [User]! @relation(name: "CHATS_IN", direction: "IN")
group: Group @relation(name: "ROOM_FOR", direction: "OUT")
roomId: String! @cypher(statement: "RETURN this.id")
isGroupRoom: Boolean!
@cypher(
statement: """
OPTIONAL MATCH (this)-[:ROOM_FOR]->(g:Group)
RETURN g IS NOT NULL
"""
)
roomName: String!
@cypher(
statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user.name"
statement: """
OPTIONAL MATCH (this)-[:ROOM_FOR]->(g:Group)
WITH this, g
OPTIONAL MATCH (this)<-[:CHATS_IN]-(user:User)
WHERE g IS NULL AND NOT user.id = $cypherParams.currentUserId
RETURN COALESCE(g.name, 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
OPTIONAL MATCH (this)-[:ROOM_FOR]->(g:Group)
OPTIONAL MATCH (g)-[:AVATAR_IMAGE]->(groupImg:Image)
WITH this, g, groupImg
OPTIONAL MATCH (this)<-[:CHATS_IN]-(user:User)
WHERE g IS NULL AND NOT user.id = $cypherParams.currentUserId
OPTIONAL MATCH (user)-[:AVATAR_IMAGE]->(userImg:Image)
RETURN COALESCE(groupImg.url, userImg.url)
"""
)
@ -45,23 +53,24 @@ type Room {
"""
)
"Count unread messages, excluding those from blocked/muted senders"
unreadCount: Int
@cypher(
statement: """
MATCH (this)<-[:INSIDE]-(message:Message)<-[:CREATED]-(user:User)
WHERE NOT user.id = $cypherParams.currentUserId
AND NOT message.seen
MATCH (u:User { id: $cypherParams.currentUserId })-[:HAS_NOT_SEEN]->(message:Message)-[:INSIDE]->(this)
MATCH (message)<-[:CREATED]-(sender:User)
WHERE NOT (u)-[:BLOCKED]->(sender) AND NOT (u)-[:MUTED]->(sender)
RETURN count(message)
"""
)
}
type Mutation {
CreateRoom(userId: ID!): Room
CreateGroupRoom(groupId: ID!): Room
}
type Query {
Room(id: ID, orderBy: [_RoomOrdering]): [Room]
Room(id: ID, userId: ID, groupId: ID, first: Int, before: String, orderBy: [_RoomOrdering]): [Room]
UnreadRooms: Int
}

View File

@ -1,4 +1,5 @@
union SearchResult = Post | User | Tag | Group
union ChatTarget = User | Group
type postSearchResults {
postCount: Int
@ -26,4 +27,5 @@ type Query {
searchGroups(query: String!, firstGroups: Int, groupsOffset: Int): groupSearchResults!
searchHashtags(query: String!, firstHashtags: Int, hashtagsOffset: Int): hashtagSearchResults!
searchResults(query: String!, limit: Int = 5): [SearchResult]!
searchChatTargets(query: String!, limit: Int = 10): [ChatTarget]!
}

View File

@ -58,7 +58,8 @@ export default {
Message: messageProperties,
},
Mutation: {
CreateRoom: roomProperties,
CreateGroupRoom: roomProperties,
CreateMessage: messageProperties,
},
Subscription: {
chatMessageAdded: messageProperties,

View File

@ -13,7 +13,6 @@ import JoinGroup from '@graphql/queries/groups/JoinGroup.gql'
import LeaveGroup from '@graphql/queries/groups/LeaveGroup.gql'
import RemoveUserFromGroup from '@graphql/queries/groups/RemoveUserFromGroup.gql'
import CreateMessage from '@graphql/queries/messaging/CreateMessage.gql'
import CreateRoom from '@graphql/queries/messaging/CreateRoom.gql'
import markAsRead from '@graphql/queries/notifications/markAsRead.gql'
import notifications from '@graphql/queries/notifications/notifications.gql'
import CreatePost from '@graphql/queries/posts/CreatePost.gql'
@ -864,6 +863,7 @@ describe('notifications', () => {
beforeEach(async () => {
jest.clearAllMocks()
isUserOnlineMock = jest.fn().mockReturnValue(false)
chatSender = await Factory.build(
'user',
@ -886,13 +886,18 @@ describe('notifications', () => {
authenticatedUser = await chatSender.toJson()
const room = await mutate({
mutation: CreateRoom,
const result = await mutate({
mutation: CreateMessage,
variables: {
userId: 'chatReceiver',
content: 'init',
},
})
roomId = room.data.CreateRoom.id
roomId = result.data.CreateMessage.room.id
// Reset mocks after init message to avoid contamination
pubsubSpy.mockClear()
;(sendChatMessageMailMock as jest.Mock).mockClear()
})
describe('if the chatReceiver is online', () => {

View File

@ -459,17 +459,28 @@ const handleCreateMessage: IMiddlewareResolver = async (
const message = await resolve(root, args, context, resolveInfo)
// Query Parameters
const { roomId } = args
const roomId = args.roomId || message?.room?.id
const {
user: { id: currentUserId },
} = context
// Find Recipient
// For CreateRoomWithMessage, roomId is not in args — query it from the message
const session = context.driver.session()
try {
const { senderUser, recipientUser, email } = await session.readTransaction(
async (transaction) => {
const messageRecipientCypher = `
let resolvedRoomId = roomId
if (!resolvedRoomId && message?.id) {
const roomResult = await session.readTransaction((transaction) => {
return transaction.run(
`MATCH (m:Message { id: $messageId })-[:INSIDE]->(room:Room) RETURN room.id AS roomId`,
{ messageId: message.id },
)
})
resolvedRoomId = roomResult.records[0]?.get('roomId')
}
if (!resolvedRoomId) return message
const { senderUser, recipients } = await session.readTransaction(async (transaction) => {
const messageRecipientsCypher = `
MATCH (senderUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId })
MATCH (room)<-[:CHATS_IN]-(recipientUser:User)-[:PRIMARY_EMAIL]->(emailAddress:EmailAddress)
WHERE NOT recipientUser.id = $currentUserId
@ -477,20 +488,25 @@ const handleCreateMessage: IMiddlewareResolver = async (
AND NOT (recipientUser)-[:MUTED]->(senderUser)
RETURN senderUser {.*}, recipientUser {.*}, emailAddress {.email}
`
const txResponse = await transaction.run(messageRecipientCypher, {
currentUserId,
roomId,
})
const txResponse = await transaction.run(messageRecipientsCypher, {
currentUserId,
roomId: resolvedRoomId,
})
return {
senderUser: txResponse.records.map((record) => record.get('senderUser'))[0],
recipientUser: txResponse.records.map((record) => record.get('recipientUser'))[0],
email: txResponse.records.map((record) => record.get('emailAddress'))[0]?.email,
}
},
)
return {
senderUser: txResponse.records.map((record) => record.get('senderUser'))[0],
recipients: txResponse.records.map((record) => ({
user: record.get('recipientUser'),
email: record.get('emailAddress')?.email,
})),
}
})
// Send subscriptions and emails to all recipients
for (const recipient of recipients) {
const recipientUser = recipient.user
const { email } = recipient
if (recipientUser) {
// send subscriptions
const roomCountUpdated = await getUnreadRoomsCount(recipientUser.id, session)
@ -499,12 +515,16 @@ const handleCreateMessage: IMiddlewareResolver = async (
userId: recipientUser.id,
})
void context.pubsub.publish(CHAT_MESSAGE_ADDED, {
chatMessageAdded: message,
chatMessageAdded: { ...message, seen: false },
userId: recipientUser.id,
})
// Send EMail if we found a user(not blocked) and he is not considered online
if (recipientUser.emailNotificationsChatMessage !== false && !isUserOnline(recipientUser)) {
if (
email &&
recipientUser.emailNotificationsChatMessage !== false &&
!isUserOnline(recipientUser)
) {
void sendChatMessageMail({ email, senderUser, recipientUser })
}
}

View File

@ -426,6 +426,7 @@ export default shield(
Query: {
'*': deny,
searchResults: allow,
searchChatTargets: isAuthenticated,
searchPosts: allow,
searchUsers: allow,
searchGroups: allow,
@ -524,7 +525,7 @@ export default shield(
markTeaserAsViewed: allow,
saveCategorySettings: isAuthenticated,
updateOnlineStatus: isAuthenticated,
CreateRoom: isAuthenticated,
CreateGroupRoom: isAuthenticated,
CreateMessage: isAuthenticated,
MarkMessagesAsSeen: isAuthenticated,
toggleObservePost: isAuthenticated,

View File

@ -0,0 +1,24 @@
Feature: Direct Messages
As a user
I want to send direct messages to other users
So that I can have private conversations
Background:
Given the following "users" are in the database:
| slug | email | password | id | name | termsAndConditionsAgreedVersion |
| alice | alice@example.org | 1234 | alice | Alice | 0.0.4 |
| bob | bob@example.org | 1234 | bob | Bob | 0.0.4 |
Scenario: Send a direct message via chat page
Given I am logged in as "alice"
And I navigate to page "/chat"
When I open a direct message with "bob"
And I send the message "Hello Bob!" in the chat
Then I see the message "Hello Bob!" in the chat
Scenario: Open a direct message via query parameter
Given I am logged in as "alice"
When I navigate to page "/chat?userId=bob"
Then I see the chat room with "Bob"
When I send the message "Hi from link!" in the chat
Then I see the message "Hi from link!" in the chat

View File

@ -0,0 +1,37 @@
Feature: Group Chat
As a group member
I want to chat with other group members in a group chat
So that we can communicate within the group
Background:
Given the following "users" are in the database:
| slug | email | password | id | name | termsAndConditionsAgreedVersion |
| alice | alice@example.org | 1234 | alice | Alice | 0.0.4 |
| bob | bob@example.org | 4321 | bob | Bob | 0.0.4 |
And the following "groups" are in the database:
| id | name | slug | ownerId | groupType | description |
| test-group | Test Group | test-group | alice | public | This is a test group for e2e testing of the group chat feature. It needs to be long enough to pass validation. |
Scenario: Open group chat from group profile
Given "bob" is a member of group "test-group"
And I am logged in as "bob"
And I navigate to page "/groups/test-group/test-group"
When I click on the group chat button
Then I see the group chat popup with name "Test Group"
Scenario: Send a message in the group chat
Given "bob" is a member of group "test-group"
And I am logged in as "bob"
And I navigate to page "/chat"
When I open the group chat for "test-group"
And I send the message "Hello Group!" in the chat
Then I see the message "Hello Group!" in the chat
Scenario: Receive a group chat notification
Given "bob" is a member of group "test-group"
And "alice" opens the group chat for "test-group"
And I am logged in as "bob"
And I navigate to page "/"
And I see no unread chat messages in the header
When "alice" sends a group chat message "Hello everyone!" to "test-group"
Then I see 1 unread chat message in the header

View File

@ -0,0 +1,31 @@
Feature: Chat Read Status
As a user
I want messages to be marked as read when I view them
So that I know which messages are new
Background:
Given the following "users" are in the database:
| slug | email | password | id | name | termsAndConditionsAgreedVersion |
| alice | alice@example.org | 1234 | alice | Alice | 0.0.4 |
| bob | bob@example.org | 1234 | bob | Bob | 0.0.4 |
| charlie | charlie@example.org | 1234 | charlie | Charlie | 0.0.4 |
Scenario: Messages are marked as read when opening a chat room
Given "alice" sends a chat message "Hey Bob!" to "bob"
And I am logged in as "bob"
And I navigate to page "/"
And I see 1 unread chat message in the header
When I navigate to page "/chat"
And I click on the room "Alice"
Then I see the message "Hey Bob!" in the chat
And I see no unread chat messages in the header
Scenario: Notification badge decreases when reading one of multiple rooms
Given "alice" sends a chat message "Hey Bob!" to "bob"
And "charlie" sends a chat message "Hi Bob!" to "bob"
And I am logged in as "bob"
And I navigate to page "/"
And I see 2 unread chat message in the header
When I navigate to page "/chat"
And I click on the room "Alice"
Then I see 1 unread chat message in the header

View File

@ -0,0 +1,29 @@
Feature: Chat Rooms
As a user
I want to manage and navigate between chat rooms
So that I can keep track of my conversations
Background:
Given the following "users" are in the database:
| slug | email | password | id | name | termsAndConditionsAgreedVersion |
| alice | alice@example.org | 1234 | alice | Alice | 0.0.4 |
| bob | bob@example.org | 1234 | bob | Bob | 0.0.4 |
| charlie | charlie@example.org | 1234 | charlie | Charlie | 0.0.4 |
Scenario: Switch between chat rooms
Given "alice" sends a chat message "Hello Bob!" to "bob"
And "charlie" sends a chat message "Hi Bob!" to "bob"
And I am logged in as "bob"
And I navigate to page "/chat"
When I click on the room "Alice"
Then I see the message "Hello Bob!" in the chat
When I click on the room "Charlie"
Then I see the message "Hi Bob!" in the chat
Scenario: Room list is sorted by latest activity
Given "alice" sends a chat message "First message" to "bob"
And "charlie" sends a chat message "Second message" to "bob"
And I am logged in as "bob"
And I navigate to page "/chat"
Then I see "Charlie" at position 1 in the room list
And I see "Alice" at position 2 in the room list

View File

@ -0,0 +1,39 @@
Feature: Chat Search
As a user
I want to search for users and groups to start conversations
So that I can easily find chat partners
Background:
Given the following "users" are in the database:
| slug | email | password | id | name | termsAndConditionsAgreedVersion |
| alice | alice@example.org | 1234 | alice | Alice | 0.0.4 |
| bob | bob@example.org | 1234 | bob | Bob | 0.0.4 |
And the following "groups" are in the database:
| id | name | slug | ownerId | groupType | description |
| test-group | Test Group | test-group | alice | public | This is a test group for e2e testing of the chat search feature. It needs to be long enough to pass validation. |
Scenario: Search for a user and start a chat
Given I am logged in as "alice"
And I navigate to page "/chat"
When I click the add chat button
And I search for "bob" in the chat search
Then I see "Bob" in the chat search results
When I select "Bob" from the chat search results
Then I see the chat room with "Bob"
Scenario: Search for a group and start a group chat
Given "alice" is a member of group "test-group"
And I am logged in as "alice"
And I navigate to page "/chat"
When I click the add chat button
And I search for "test-group" in the chat search
Then I see "Test Group" in the chat search results
When I select "Test Group" from the chat search results
Then I see the chat room with "Test Group"
Scenario: Search with less than 3 characters shows no results
Given I am logged in as "alice"
And I navigate to page "/chat"
When I click the add chat button
And I search for "bo" in the chat search
Then I see no chat search results

View File

@ -0,0 +1,11 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I open a direct message with {string}', (userSlug) => {
cy.get('vue-advanced-chat', { timeout: 10000 })
.shadow()
.find('.vac-add-icon')
.should('be.visible')
.click()
cy.get('input#chat-search-combined', { timeout: 10000 }).type(userSlug)
cy.get('.chat-search-result-detail', { timeout: 10000 }).contains(`@${userSlug}`).click()
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I click on the group chat button', () => {
cy.contains('button', /Gruppenchat|Group Chat/i, { timeout: 10000 }).click()
})

View File

@ -0,0 +1,11 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I open the group chat for {string}', (groupSlug) => {
cy.get('vue-advanced-chat', { timeout: 10000 })
.shadow()
.find('.vac-add-icon')
.should('be.visible')
.click()
cy.get('input#chat-search-combined', { timeout: 10000 }).type(groupSlug)
cy.get('.chat-search-result-detail', { timeout: 10000 }).contains(`&${groupSlug}`).click()
})

View File

@ -0,0 +1,8 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I see the group chat popup with name {string}', (groupName) => {
cy.get('.chat-modul', { timeout: 15000 }).should('be.visible')
// room-header-info slot is rendered in the light DOM of the web component
cy.get('.chat-modul vue-advanced-chat .vac-room-name', { timeout: 60000 })
.should('contain', groupName)
})

View File

@ -0,0 +1,10 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I see {string} at position {int} in the room list', (roomName, position) => {
cy.get('vue-advanced-chat', { timeout: 15000 })
.shadow()
.find('.vac-room-item')
.eq(position - 1)
.find('.vac-room-name')
.should('contain', roomName)
})

View File

@ -0,0 +1,9 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I click the add chat button', () => {
cy.get('vue-advanced-chat', { timeout: 10000 })
.shadow()
.find('.vac-add-icon')
.should('be.visible')
.click()
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I search for {string} in the chat search', (query) => {
cy.get('input#chat-search-combined', { timeout: 10000 }).type(query)
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I see no chat search results', () => {
cy.get('.chat-search-result-item').should('not.exist')
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I see {string} in the chat search results', (name) => {
cy.get('.chat-search-result-name', { timeout: 10000 }).should('contain', name)
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I select {string} from the chat search results', (name) => {
cy.contains('.chat-search-result-name', name, { timeout: 10000 }).click()
})

View File

@ -0,0 +1,8 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I click on the room {string}', (roomName) => {
cy.get('vue-advanced-chat', { timeout: 15000 })
.shadow()
.contains('.vac-room-name', roomName, { timeout: 15000 })
.click()
})

View File

@ -0,0 +1,5 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I see the chat room with {string}', (roomName) => {
cy.get('vue-advanced-chat .vac-room-name', { timeout: 15000 }).should('contain', roomName)
})

View File

@ -0,0 +1,8 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I see the message {string} in the chat', (message) => {
cy.get('vue-advanced-chat', { timeout: 15000 })
.shadow()
.find('.vac-message-wrapper')
.should('contain', message)
})

View File

@ -0,0 +1,9 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I send the message {string} in the chat', (message) => {
cy.get('vue-advanced-chat', { timeout: 15000 })
.shadow()
.find('.vac-textarea')
.should('be.visible')
.type(`${message}{enter}`)
})

View File

@ -24,6 +24,11 @@ defineStep('the following {string} are in the database:', (table,data) => {
break
case 'users':
data.hashes().forEach( entry => {
if (entry.slug && entry.password) {
const passwords = Cypress.env('userPasswords') || {}
passwords[entry.slug] = entry.password
Cypress.env('userPasswords', passwords)
}
cy.factory().build('user', entry, entry)
})
break

View File

@ -0,0 +1,16 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
import './../../factories'
defineStep('{string} is a member of group {string}', (userSlug, groupId) => {
cy.neode().then((neode) => {
return neode.writeCypher(
`MATCH (user:User {slug: $userSlug}), (group:Group {id: $groupId})
MERGE (user)-[membership:MEMBER_OF]->(group)
ON CREATE SET
membership.createdAt = toString(datetime()),
membership.updatedAt = toString(datetime()),
membership.role = 'usual'`,
{ userSlug, groupId },
)
})
})

View File

@ -0,0 +1,30 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
import './../../commands'
import './../../factories'
const createGroupRoomMutation = `
mutation ($groupId: ID!) {
CreateGroupRoom(groupId: $groupId) {
id
}
}
`
defineStep('{string} opens the group chat for {string}', (userSlug, groupId) => {
cy.neode()
.then((neode) => {
return neode.cypher(
`MATCH (user:User {slug: $userSlug})-[:PRIMARY_EMAIL]->(e:EmailAddress)
RETURN e.email AS email`,
{ userSlug },
)
})
.then((result) => {
const email = result.records[0].get('email')
const password = (Cypress.env('userPasswords') || {})[userSlug]
expect(password, `No password found for user "${userSlug}"`).to.exist
return cy.authenticateAs({ email, password }).then((client) => {
return client.request(createGroupRoomMutation, { groupId })
})
})
})

View File

@ -2,17 +2,9 @@ import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
import './../../commands'
import './../../factories'
const createRoomMutation = `
mutation ($userId: ID!) {
CreateRoom(userId: $userId) {
id
}
}
`
const createMessageMutation = `
mutation ($roomId: ID!, $content: String) {
CreateMessage(roomId: $roomId, content: $content) {
mutation ($userId: ID, $content: String) {
CreateMessage(userId: $userId, content: $content) {
id
}
}
@ -35,11 +27,10 @@ defineStep(
`No users found for sender "${senderSlug}" or recipient "${recipientSlug}"`)
const senderEmail = result.records[0].get('senderEmail')
const recipientId = result.records[0].get('recipientId')
return cy.authenticateAs({ email: senderEmail, password: '1234' }).then((client) => {
return client.request(createRoomMutation, { userId: recipientId }).then((roomData) => {
const roomId = roomData.CreateRoom.id
return client.request(createMessageMutation, { roomId, content: message })
})
const password = (Cypress.env('userPasswords') || {})[senderSlug]
expect(password, `No password found for user "${senderSlug}"`).to.exist
return cy.authenticateAs({ email: senderEmail, password }).then((client) => {
return client.request(createMessageMutation, { userId: recipientId, content: message })
})
})
},

View File

@ -0,0 +1,46 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
import './../../commands'
import './../../factories'
const createGroupRoomMutation = `
mutation ($groupId: ID!) {
CreateGroupRoom(groupId: $groupId) {
roomId
}
}
`
const createMessageMutation = `
mutation ($roomId: ID!, $content: String) {
CreateMessage(roomId: $roomId, content: $content) {
id
}
}
`
defineStep(
'{string} sends a group chat message {string} to {string}',
(senderSlug, message, groupId) => {
cy.neode()
.then((neode) => {
return neode.cypher(
`MATCH (sender:User {slug: $senderSlug})-[:PRIMARY_EMAIL]->(e:EmailAddress)
RETURN e.email AS senderEmail`,
{ senderSlug },
)
})
.then((result) => {
const senderEmail = result.records[0].get('senderEmail')
const password = (Cypress.env('userPasswords') || {})[senderSlug]
expect(password, `No password found for user "${senderSlug}"`).to.exist
return cy.authenticateAs({ email: senderEmail, password }).then((client) => {
return client
.request(createGroupRoomMutation, { groupId })
.then((roomData) => {
const roomId = roomData.CreateGroupRoom.roomId
return client.request(createMessageMutation, { roomId, content: message })
})
})
})
},
)

View File

@ -0,0 +1,215 @@
import { mount } from '@vue/test-utils'
import AddChatRoomByUserSearch from './AddChatRoomByUserSearch.vue'
const localVue = global.localVue
const stubs = {
'os-button': {
template:
'<button @click="$listeners.click && $listeners.click()"><slot /><slot name="icon" /></button>',
},
'os-icon': { template: '<span />' },
'os-badge': { template: '<span><slot /></span>' },
'profile-avatar': { template: '<div />' },
'ocelot-select': {
template:
'<div class="ocelot-select"><slot /><slot name="option" :option="{ name: \'test\' }" /></div>',
props: ['value', 'options', 'loading', 'filter', 'placeholder', 'noOptionsAvailable'],
},
}
describe('AddChatRoomByUserSearch.vue', () => {
let wrapper, mocks
beforeEach(() => {
mocks = {
$t: jest.fn((key) => key),
$apollo: {
queries: {
searchChatTargets: { loading: false },
},
},
}
})
const Wrapper = () => {
return mount(AddChatRoomByUserSearch, {
localVue,
mocks,
stubs,
})
}
describe('mount', () => {
it('renders', () => {
wrapper = Wrapper()
expect(wrapper.exists()).toBe(true)
})
it('shows search headline', () => {
wrapper = Wrapper()
expect(mocks.$t).toHaveBeenCalledWith('chat.addRoomHeadline')
})
})
describe('startSearch computed', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('returns false for empty query', () => {
wrapper.vm.query = ''
expect(wrapper.vm.startSearch).toBeFalsy()
})
it('returns false for short query', () => {
wrapper.vm.query = 'ab'
expect(wrapper.vm.startSearch).toBe(false)
})
it('returns true for query with 3+ characters', () => {
wrapper.vm.query = 'abc'
expect(wrapper.vm.startSearch).toBe(true)
})
})
describe('handleInput', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('sets query from event target', () => {
wrapper.vm.handleInput({ target: { value: ' hello ' } })
expect(wrapper.vm.query).toBe('hello')
})
it('sets empty string when no target', () => {
wrapper.vm.handleInput({})
expect(wrapper.vm.query).toBe('')
})
it('clears results when query is too short', () => {
wrapper.vm.results = [{ id: '1' }]
wrapper.vm.handleInput({ target: { value: 'ab' } })
expect(wrapper.vm.results).toEqual([])
})
})
describe('onDelete', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('clears when value is empty', () => {
wrapper.vm.query = 'test'
wrapper.vm.results = [{ id: '1' }]
wrapper.vm.onDelete({ target: { value: '' } })
expect(wrapper.vm.query).toBe('')
expect(wrapper.vm.results).toEqual([])
})
it('calls handleInput when value is not empty', () => {
wrapper.vm.onDelete({ target: { value: 'abc' } })
expect(wrapper.vm.query).toBe('abc')
})
})
describe('clear', () => {
it('resets query and results', () => {
wrapper = Wrapper()
wrapper.vm.query = 'test'
wrapper.vm.results = [{ id: '1' }]
wrapper.vm.clear()
expect(wrapper.vm.query).toBe('')
expect(wrapper.vm.results).toEqual([])
})
})
describe('onBlur', () => {
beforeEach(() => {
jest.useFakeTimers()
wrapper = Wrapper()
})
afterEach(() => {
jest.useRealTimers()
})
it('clears query and results after timeout', () => {
wrapper.vm.query = 'test'
wrapper.vm.results = [{ id: '1' }]
wrapper.vm.onBlur()
expect(wrapper.vm.query).toBe('test')
jest.advanceTimersByTime(200)
expect(wrapper.vm.query).toBe('')
expect(wrapper.vm.results).toEqual([])
})
})
describe('onSelect', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('ignores null', () => {
wrapper.vm.onSelect(null)
expect(wrapper.emitted('add-chat-room')).toBeFalsy()
})
it('ignores string', () => {
wrapper.vm.onSelect('some string')
expect(wrapper.emitted('add-chat-room')).toBeFalsy()
})
it('ignores items without __typename', () => {
wrapper.vm.onSelect({ id: '1', name: 'Test' })
expect(wrapper.emitted('add-chat-room')).toBeFalsy()
})
it('emits add-group-chat-room for Group items', async () => {
wrapper.vm.onSelect({ id: 'g1', name: 'Group', __typename: 'Group' })
expect(wrapper.emitted('add-group-chat-room')).toBeTruthy()
expect(wrapper.emitted('add-group-chat-room')[0]).toEqual(['g1'])
})
it('emits add-chat-room for User items', () => {
wrapper.vm.onSelect({
id: 'u1',
name: 'User',
slug: 'user',
avatar: 'avatar.jpg',
__typename: 'User',
})
expect(wrapper.emitted('add-chat-room')).toBeTruthy()
expect(wrapper.emitted('add-chat-room')[0]).toEqual([
{ id: 'u1', name: 'User', slug: 'user', avatar: 'avatar.jpg' },
])
})
it('emits close-user-search after selection', async () => {
wrapper.vm.onSelect({ id: 'u1', name: 'User', __typename: 'User' })
await wrapper.vm.$nextTick()
expect(wrapper.emitted('close-user-search')).toBeTruthy()
})
})
describe('closeSearch', () => {
it('emits close-user-search', () => {
wrapper = Wrapper()
wrapper.vm.closeSearch()
expect(wrapper.emitted('close-user-search')).toBeTruthy()
})
})
describe('beforeDestroy', () => {
it('clears blur timeout', () => {
jest.useFakeTimers()
wrapper = Wrapper()
wrapper.vm.onBlur()
wrapper.destroy()
jest.advanceTimersByTime(200)
// No error thrown = timeout was cleared
jest.useRealTimers()
})
})
})

View File

@ -9,7 +9,7 @@
circle
size="sm"
:aria-label="$t('actions.close')"
@click="closeUserSearch"
@click="closeSearch"
>
<template #icon>
<os-icon :icon="icons.close" />
@ -18,57 +18,158 @@
</div>
<div class="ds-mb-small"></div>
<div class="ds-mb-large">
<select-user-search :id="id" ref="selectUserSearch" @select-user="selectUser" />
<ocelot-select
class="chat-search-combined"
type="search"
icon="search"
label-prop="id"
:value="selectedItem"
id="chat-search-combined"
:icon-right="null"
:options="results"
:loading="$apollo.queries.searchChatTargets.loading"
:filter="(item) => item"
:no-options-available="$t('chat.searchPlaceholder')"
:auto-reset-search="true"
:placeholder="$t('chat.searchPlaceholder')"
@input.native="handleInput"
@keyup.delete.native="onDelete"
@keyup.esc.native="clear"
@blur.capture.native="onBlur"
@input="onSelect"
>
<template #option="{ option }">
<div class="chat-search-result-item">
<profile-avatar :profile="option" size="small" />
<div class="chat-search-result-info">
<span class="chat-search-result-name">{{ option.name }}</span>
<span class="chat-search-result-detail">
{{ option.__typename === 'Group' ? `&${option.slug}` : `@${option.slug}` }}
</span>
</div>
<os-badge size="sm" class="chat-search-result-badge">
{{
option.__typename === 'Group'
? $t('chat.searchBadgeGroup')
: $t('chat.searchBadgeUser')
}}
</os-badge>
</div>
</template>
</ocelot-select>
</div>
</div>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { OsButton, OsIcon, OsBadge } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import SelectUserSearch from '~/components/generic/SelectUserSearch/SelectUserSearch'
import { isEmpty } from 'lodash'
import { searchChatTargets } from '~/graphql/Search.js'
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
import OcelotSelect from '~/components/OcelotSelect/OcelotSelect.vue'
export default {
name: 'AddChatRoomByUserSearch',
components: {
OsButton,
OsIcon,
SelectUserSearch,
},
props: {
// chatRooms: {
// type: Array,
// default: [],
// },
OsBadge,
ProfileAvatar,
OcelotSelect,
},
data() {
return {
id: 'search-user-to-add-to-group',
user: {},
query: '',
selectedItem: null,
results: [],
blurTimeout: null,
}
},
computed: {
startSearch() {
return this.query && this.query.length >= 3
},
},
beforeDestroy() {
clearTimeout(this.blurTimeout)
},
created() {
this.icons = iconRegistry
},
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)
onBlur() {
clearTimeout(this.blurTimeout)
this.blurTimeout = setTimeout(() => {
this.query = ''
this.results = []
}, 200)
},
async addChatRoom(userId) {
this.$emit('add-chat-room', userId)
handleInput(event) {
this.query = event.target ? event.target.value.trim() : ''
if (!this.startSearch) {
this.results = []
}
},
closeUserSearch() {
onDelete(event) {
const value = event.target ? event.target.value.trim() : ''
if (isEmpty(value)) {
this.clear()
} else {
this.handleInput(event)
}
},
clear() {
this.query = ''
this.results = []
},
onSelect(item) {
if (!item || typeof item === 'string') return
if (!item.__typename) return
clearTimeout(this.blurTimeout)
this.selectedItem = item
if (item.__typename === 'Group') {
this.$emit('add-group-chat-room', item.id)
} else {
this.$emit('add-chat-room', {
id: item.id,
name: item.name,
slug: item.slug,
avatar: item.avatar,
})
}
this.$nextTick(() => {
this.$emit('close-user-search')
})
},
closeSearch() {
this.$emit('close-user-search')
},
},
apollo: {
searchChatTargets: {
query() {
return searchChatTargets
},
variables() {
return {
query: this.query,
limit: 10,
}
},
skip() {
return !this.startSearch
},
update({ searchChatTargets }) {
this.results = searchChatTargets.map((item) => ({
...item,
// Normalize Group name field (groupName alias name)
name: item.name || item.groupName,
}))
},
fetchPolicy: 'no-cache',
},
},
}
</script>
@ -76,6 +177,7 @@ export default {
.add-chat-room-by-user-search {
background-color: white;
padding: $space-base;
scroll-margin-top: 7rem;
}
.ds-flex.headline {
justify-content: space-between;
@ -83,4 +185,37 @@ export default {
.ds-flex.headline .close-button {
margin-top: -2px;
}
.chat-search-result-item {
display: flex;
align-items: center;
width: 100%;
}
.chat-search-result-info {
margin-left: $space-x-small;
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.chat-search-result-name {
font-weight: $font-weight-bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-search-result-detail {
font-size: $font-size-small;
color: $text-color-soft;
}
.chat-search-result-badge {
margin-left: auto;
flex-shrink: 0;
white-space: nowrap;
// Tailwind @layer utilities have lower specificity than unlayered CSS,
// so we need to explicitly set the badge styles here.
font-size: 0.75rem;
padding: 0.2em 0.8em;
border-radius: 2em;
line-height: 1.3;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,8 @@ import gql from 'graphql-tag'
export const createMessageMutation = () => {
return gql`
mutation ($roomId: ID!, $content: String, $files: [FileInput]) {
CreateMessage(roomId: $roomId, content: $content, files: $files) {
mutation ($roomId: ID, $userId: ID, $content: String, $files: [FileInput]) {
CreateMessage(roomId: $roomId, userId: $userId, content: $content, files: $files) {
#_id
id
indexId
@ -35,8 +35,14 @@ export const createMessageMutation = () => {
export const messageQuery = () => {
return gql`
query ($roomId: ID!, $first: Int, $offset: Int) {
Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) {
query ($roomId: ID!, $first: Int, $offset: Int, $beforeIndex: Int) {
Message(
roomId: $roomId
first: $first
offset: $offset
beforeIndex: $beforeIndex
orderBy: indexId_desc
) {
_id
id
indexId
@ -103,6 +109,18 @@ export const chatMessageAdded = () => {
`
}
export const chatMessageStatusUpdated = () => {
return gql`
subscription chatMessageStatusUpdated {
chatMessageStatusUpdated {
roomId
messageIds
status
}
}
`
}
export const markMessagesAsSeen = () => {
return gql`
mutation ($messageIds: [String!]) {

View File

@ -1,18 +1,27 @@
import gql from 'graphql-tag'
import { imageUrls } from './fragments/imageUrls'
export const createRoom = () => gql`
export const createGroupRoom = () => gql`
${imageUrls}
mutation ($userId: ID!) {
CreateRoom(userId: $userId) {
mutation ($groupId: ID!) {
CreateGroupRoom(groupId: $groupId) {
id
roomId
roomName
avatar
isGroupRoom
lastMessageAt
createdAt
unreadCount
#avatar
group {
id
name
slug
avatar {
...imageUrls
}
}
users {
_id
id
@ -28,15 +37,24 @@ export const createRoom = () => gql`
export const roomQuery = () => gql`
${imageUrls}
query Room($first: Int, $offset: Int, $id: ID) {
Room(first: $first, offset: $offset, id: $id, orderBy: [createdAt_desc, lastMessageAt_desc]) {
query Room($first: Int, $before: String, $id: ID, $userId: ID, $groupId: ID) {
Room(first: $first, before: $before, id: $id, userId: $userId, groupId: $groupId) {
id
roomId
roomName
avatar
isGroupRoom
lastMessageAt
createdAt
unreadCount
group {
id
name
slug
avatar {
...imageUrls
}
}
lastMessage {
_id
id

View File

@ -137,3 +137,30 @@ export const searchHashtags = gql`
}
}
`
export const searchChatTargets = gql`
${imageUrls}
query ($query: String!, $limit: Int) {
searchChatTargets(query: $query, limit: $limit) {
__typename
... on User {
id
slug
name
avatar {
...imageUrls
}
}
... on Group {
id
slug
groupName: name
avatar {
...imageUrls
}
myRole
}
}
}
`

View File

@ -18,7 +18,7 @@ module.exports = {
],
coverageThreshold: {
global: {
lines: 84,
lines: 85,
},
},
coverageProvider: 'v8',

View File

@ -37,7 +37,7 @@ describe('default.vue', () => {
getters: {
'auth/isLoggedIn': () => true,
'chat/showChat': () => {
return { showChat: false, roomID: null }
return { showChat: false, chatUserId: null, groupId: null }
},
},
mutations: {

View File

@ -10,9 +10,14 @@
</div>
<page-footer class="desktop-footer" />
<div id="overlay" />
<div v-if="getShowChat.showChat" class="chat-modul">
<div v-if="getShowChat.showChat && !isMobile" class="chat-modul">
<client-only>
<chat singleRoom :roomId="getShowChat.roomID" @close-single-room="closeSingleRoom" />
<chat
singleRoom
:userId="getShowChat.chatUserId"
:groupId="getShowChat.groupId"
@close-single-room="closeSingleRoom"
/>
</client-only>
</div>
</div>
@ -38,16 +43,33 @@ export default {
getShowChat: 'chat/showChat',
}),
},
watch: {
'getShowChat.showChat'(open) {
if (open && this.isMobile) {
const { chatUserId, groupId } = this.getShowChat
const query = {}
if (chatUserId) query.userId = chatUserId
if (groupId) query.groupId = groupId
this.showChat({ showChat: false, chatUserId: null, groupId: null })
if (this.$route.path === '/chat') {
// Already on chat page update query to trigger openFromQuery watcher
this.$router.replace({ path: '/chat', query })
} else {
this.$router.push({ path: '/chat', query })
}
}
},
},
methods: {
...mapMutations({
showChat: 'chat/SET_OPEN_CHAT',
}),
closeSingleRoom() {
this.showChat({ showChat: false, roomID: null })
this.showChat({ showChat: false, chatUserId: null, groupId: null })
},
},
beforeCreate() {
this.$store.commit('chat/SET_OPEN_CHAT', { showChat: false, roomID: null })
this.$store.commit('chat/SET_OPEN_CHAT', { showChat: false, chatUserId: null, groupId: null })
},
}
</script>

View File

@ -123,23 +123,33 @@
}
},
"chat": {
"addGroupRoomHeadline": "Suche Gruppe für neuen Chat",
"addRoomHeadline": "Suche Nutzer für neuen Chat",
"cancelSelectMessage": "Abbrechen",
"closeChat": "Chat schließen",
"conversationStarted": "Unterhaltung startete am:",
"expandChat": "Chat erweitern",
"groupChatButton": {
"label": "Gruppenchat",
"tooltip": "Chatte mit der Gruppe „{name}“"
},
"isOnline": "online",
"isTyping": "tippt...",
"lastSeen": "zuletzt gesehen ",
"messageDeleted": "Diese Nachricht wurde gelöscht",
"messagesEmpty": "Keine Nachrichten",
"newMessages": "Neue Nachrichten",
"noGroupsFound": "Keine Gruppen gefunden",
"page": {
"headline": "Chat"
},
"roomEmpty": "Keinen Raum selektiert",
"roomsEmpty": "Keine Räume",
"search": "Chat-Räume filtern",
"searchBadgeGroup": "Gruppe",
"searchBadgeUser": "Nutzer",
"searchGroupPlaceholder": "Gruppe suchen …",
"searchPlaceholder": "Nutzer oder Gruppe suchen…",
"transmitting": "Nachricht wird übertragen ...",
"typeMessage": "Nachricht schreiben",
"userProfileButton": {

View File

@ -123,23 +123,33 @@
}
},
"chat": {
"addGroupRoomHeadline": "Search group for new chat",
"addRoomHeadline": "Search User for new Chat",
"cancelSelectMessage": "Cancel",
"closeChat": "Close chat",
"conversationStarted": "Conversation started on:",
"expandChat": "Expand chat",
"groupChatButton": {
"label": "Group Chat",
"tooltip": "Chat with group “{name}”"
},
"isOnline": "is online",
"isTyping": "is writing...",
"lastSeen": "last seen ",
"messageDeleted": "This message was deleted",
"messagesEmpty": "No messages",
"newMessages": "New Messages",
"noGroupsFound": "No groups found",
"page": {
"headline": "Chat"
},
"roomEmpty": "No room selected",
"roomsEmpty": "No rooms",
"search": "Filter chat rooms",
"searchBadgeGroup": "Group",
"searchBadgeUser": "User",
"searchGroupPlaceholder": "Search group…",
"searchPlaceholder": "Search user or group…",
"transmitting": "Transmitting message ...",
"typeMessage": "Type message",
"userProfileButton": {

View File

@ -123,23 +123,33 @@
}
},
"chat": {
"addGroupRoomHeadline": "Buscar grupo para nuevo chat",
"addRoomHeadline": "Buscar usuario para nuevo chat",
"cancelSelectMessage": "Cancelar",
"closeChat": "Cerrar chat",
"conversationStarted": "Conversación iniciada el:",
"expandChat": "Expandir chat",
"groupChatButton": {
"label": "Chat grupal",
"tooltip": "Chat con el grupo \"{name}\""
},
"isOnline": "está en línea",
"isTyping": "está escribiendo...",
"lastSeen": "visto por última vez ",
"messageDeleted": "Este mensaje fue eliminado",
"messagesEmpty": "No hay mensajes",
"newMessages": "Nuevos mensajes",
"noGroupsFound": "No se encontraron grupos",
"page": {
"headline": "Chat"
},
"roomEmpty": "No hay sala seleccionada",
"roomsEmpty": "No hay salas",
"search": "Filtrar salas de chat",
"searchBadgeGroup": "Grupo",
"searchBadgeUser": "Usuario",
"searchGroupPlaceholder": "Buscar grupo…",
"searchPlaceholder": "Buscar usuario o grupo…",
"transmitting": "Enviando mensaje ...",
"typeMessage": "Escribe un mensaje",
"userProfileButton": {

View File

@ -123,23 +123,33 @@
}
},
"chat": {
"addGroupRoomHeadline": "Chercher un groupe pour un nouveau chat",
"addRoomHeadline": "Rechercher un utilisateur pour un nouveau chat",
"cancelSelectMessage": "Annuler",
"closeChat": "Fermer le chat",
"conversationStarted": "Conversation commencée le :",
"expandChat": "Agrandir le chat",
"groupChatButton": {
"label": "Chat de groupe",
"tooltip": "Discuter avec le groupe « {name} »"
},
"isOnline": "est en ligne",
"isTyping": "écrit...",
"lastSeen": "vu pour la dernière fois ",
"messageDeleted": "Ce message a été supprimé",
"messagesEmpty": "Aucun message",
"newMessages": "Nouveaux messages",
"noGroupsFound": "Aucun groupe trouvé",
"page": {
"headline": "Chat"
},
"roomEmpty": "Aucun salon sélectionné",
"roomsEmpty": "Aucun salon",
"search": "Filtrer les salons de chat",
"searchBadgeGroup": "Groupe",
"searchBadgeUser": "Utilisateur",
"searchGroupPlaceholder": "Chercher un groupe…",
"searchPlaceholder": "Chercher un utilisateur ou un groupe…",
"transmitting": "Envoi du message ...",
"typeMessage": "Écris un message",
"userProfileButton": {

View File

@ -123,23 +123,33 @@
}
},
"chat": {
"addGroupRoomHeadline": "Cerca gruppo per nuova chat",
"addRoomHeadline": "Cerca utente per nuova chat",
"cancelSelectMessage": "Annulla",
"closeChat": "Chiudi chat",
"conversationStarted": "Conversazione iniziata il:",
"expandChat": "Espandi chat",
"groupChatButton": {
"label": "Chat di gruppo",
"tooltip": "Chatta con il gruppo \"{name}\""
},
"isOnline": "è online",
"isTyping": "sta scrivendo...",
"lastSeen": "ultimo accesso ",
"messageDeleted": "Questo messaggio è stato eliminato",
"messagesEmpty": "Nessun messaggio",
"newMessages": "Nuovi messaggi",
"noGroupsFound": "Nessun gruppo trovato",
"page": {
"headline": "Chat"
},
"roomEmpty": "Nessuna stanza selezionata",
"roomsEmpty": "Nessuna stanza",
"search": "Filtra stanze chat",
"searchBadgeGroup": "Gruppo",
"searchBadgeUser": "Utente",
"searchGroupPlaceholder": "Cerca gruppo…",
"searchPlaceholder": "Cerca utente o gruppo…",
"transmitting": "Invio messaggio ...",
"typeMessage": "Scrivi un messaggio",
"userProfileButton": {

View File

@ -123,23 +123,33 @@
}
},
"chat": {
"addGroupRoomHeadline": "Zoek groep voor nieuwe chat",
"addRoomHeadline": "Zoek gebruiker voor nieuwe chat",
"cancelSelectMessage": "Annuleren",
"closeChat": "Chat sluiten",
"conversationStarted": "Gesprek gestart op:",
"expandChat": "Chat uitvouwen",
"groupChatButton": {
"label": "Groepschat",
"tooltip": "Chat met groep \"{name}\""
},
"isOnline": "is online",
"isTyping": "is aan het typen...",
"lastSeen": "laatst gezien ",
"messageDeleted": "Dit bericht is verwijderd",
"messagesEmpty": "Geen berichten",
"newMessages": "Nieuwe berichten",
"noGroupsFound": "Geen groepen gevonden",
"page": {
"headline": "Chat"
},
"roomEmpty": "Geen ruimte geselecteerd",
"roomsEmpty": "Geen ruimtes",
"search": "Chatruimtes filteren",
"searchBadgeGroup": "Groep",
"searchBadgeUser": "Gebruiker",
"searchGroupPlaceholder": "Groep zoeken…",
"searchPlaceholder": "Gebruiker of groep zoeken…",
"transmitting": "Bericht verzenden …",
"typeMessage": "Typ een bericht",
"userProfileButton": {

View File

@ -123,23 +123,33 @@
}
},
"chat": {
"addGroupRoomHeadline": "Szukaj grupy do nowego czatu",
"addRoomHeadline": "Szukaj użytkownika do nowego czatu",
"cancelSelectMessage": "Anuluj",
"closeChat": "Zamknij czat",
"conversationStarted": "Rozmowa rozpoczęta:",
"expandChat": "Rozwiń czat",
"groupChatButton": {
"label": "Czat grupowy",
"tooltip": "Czat z grupą „{name}”"
},
"isOnline": "jest online",
"isTyping": "pisze...",
"lastSeen": "ostatnio widziano ",
"messageDeleted": "Ta wiadomość została usunięta",
"messagesEmpty": "Brak wiadomości",
"newMessages": "Nowe wiadomości",
"noGroupsFound": "Nie znaleziono grup",
"page": {
"headline": "Czat"
},
"roomEmpty": "Nie wybrano pokoju",
"roomsEmpty": "Brak pokojów",
"search": "Filtruj pokoje czatu",
"searchBadgeGroup": "Grupa",
"searchBadgeUser": "Użytkownik",
"searchGroupPlaceholder": "Szukaj grupy…",
"searchPlaceholder": "Szukaj użytkownika lub grupy…",
"transmitting": "Wysyłanie wiadomości …",
"typeMessage": "Wpisz wiadomość",
"userProfileButton": {

View File

@ -123,23 +123,33 @@
}
},
"chat": {
"addGroupRoomHeadline": "Procurar grupo para novo chat",
"addRoomHeadline": "Buscar usuário para novo chat",
"cancelSelectMessage": "Cancelar",
"closeChat": "Fechar chat",
"conversationStarted": "Conversa iniciada em:",
"expandChat": "Expandir chat",
"groupChatButton": {
"label": "Chat de grupo",
"tooltip": "Chat com o grupo \"{name}\""
},
"isOnline": "está online",
"isTyping": "está escrevendo...",
"lastSeen": "visto por último ",
"messageDeleted": "Esta mensagem foi excluída",
"messagesEmpty": "Sem mensagens",
"newMessages": "Novas mensagens",
"noGroupsFound": "Nenhum grupo encontrado",
"page": {
"headline": "Chat"
},
"roomEmpty": "Nenhuma sala selecionada",
"roomsEmpty": "Sem salas",
"search": "Filtrar salas de chat",
"searchBadgeGroup": "Grupo",
"searchBadgeUser": "Utilizador",
"searchGroupPlaceholder": "Procurar grupo…",
"searchPlaceholder": "Procurar utilizador ou grupo…",
"transmitting": "Enviando mensagem ...",
"typeMessage": "Escreva uma mensagem",
"userProfileButton": {

View File

@ -123,23 +123,33 @@
}
},
"chat": {
"addGroupRoomHeadline": "Поиск группы для нового чата",
"addRoomHeadline": "Поиск пользователя для нового чата",
"cancelSelectMessage": "Отмена",
"closeChat": "Закрыть чат",
"conversationStarted": "Беседа начата:",
"expandChat": "Развернуть чат",
"groupChatButton": {
"label": "Групповой чат",
"tooltip": "Чат с группой «{name}»"
},
"isOnline": "в сети",
"isTyping": "пишет...",
"lastSeen": "был в сети ",
"messageDeleted": "Это сообщение было удалено",
"messagesEmpty": "Нет сообщений",
"newMessages": "Новые сообщения",
"noGroupsFound": "Группы не найдены",
"page": {
"headline": "Чат"
},
"roomEmpty": "Комната не выбрана",
"roomsEmpty": "Нет комнат",
"search": "Фильтр комнат чата",
"searchBadgeGroup": "Группа",
"searchBadgeUser": "Пользователь",
"searchGroupPlaceholder": "Искать группу…",
"searchPlaceholder": "Искать пользователя или группу…",
"transmitting": "Отправка сообщения ...",
"typeMessage": "Напиши сообщение",
"userProfileButton": {

View File

@ -123,23 +123,33 @@
}
},
"chat": {
"addGroupRoomHeadline": "Kërko grup për bisedë të re",
"addRoomHeadline": "Kërko përdorues për bisedë të re",
"cancelSelectMessage": "Anulo",
"closeChat": "Mbyll bisedën",
"conversationStarted": "Biseda filloi më:",
"expandChat": "Zgjero bisedën",
"groupChatButton": {
"label": "Bisedë grupi",
"tooltip": "Bisedo me grupin \"{name}\""
},
"isOnline": "është online",
"isTyping": "po shkruan...",
"lastSeen": "parë për herë të fundit ",
"messageDeleted": "Ky mesazh u fshi",
"messagesEmpty": "Nuk ka mesazhe",
"newMessages": "Mesazhe të reja",
"noGroupsFound": "Nuk u gjetën grupe",
"page": {
"headline": "Biseda"
},
"roomEmpty": "Asnjë dhomë e zgjedhur",
"roomsEmpty": "Nuk ka dhoma",
"search": "Filtro dhomat e bisedës",
"searchBadgeGroup": "Grup",
"searchBadgeUser": "Përdorues",
"searchGroupPlaceholder": "Kërko grup…",
"searchPlaceholder": "Kërko përdorues ose grup…",
"transmitting": "Duke dërguar mesazhin …",
"typeMessage": "Shkruaj mesazh",
"userProfileButton": {

View File

@ -123,23 +123,33 @@
}
},
"chat": {
"addGroupRoomHeadline": "Пошук групи для нового чату",
"addRoomHeadline": "Пошук користувача для нового чату",
"cancelSelectMessage": "Скасувати",
"closeChat": "Закрити чат",
"conversationStarted": "Розмову розпочато:",
"expandChat": "Розгорнути чат",
"groupChatButton": {
"label": "Груповий чат",
"tooltip": "Чат з групою «{name}»"
},
"isOnline": "в мережі",
"isTyping": "пише...",
"lastSeen": "останній раз ",
"messageDeleted": "Це повідомлення було видалено",
"messagesEmpty": "Немає повідомлень",
"newMessages": "Нові повідомлення",
"noGroupsFound": "Групи не знайдено",
"page": {
"headline": "Чат"
},
"roomEmpty": "Кімнату не вибрано",
"roomsEmpty": "Немає кімнат",
"search": "Фільтрувати чат-кімнати",
"searchBadgeGroup": "Група",
"searchBadgeUser": "Користувач",
"searchGroupPlaceholder": "Шукати групу…",
"searchPlaceholder": "Шукати користувача або групу…",
"transmitting": "Надсилання повідомлення ...",
"typeMessage": "Написати повідомлення",
"userProfileButton": {

View File

@ -1,15 +1,17 @@
<template>
<div>
<div class="chat-page">
<add-chat-room-by-user-search
v-if="showUserSearch"
v-if="showSearch"
ref="searchPanel"
@add-chat-room="addChatRoom"
@close-user-search="showUserSearch = false"
@add-group-chat-room="addGroupChatRoom"
@close-user-search="showSearch = false"
/>
<client-only>
<chat
:roomId="getShowChat.showChat ? getShowChat.roomID : null"
:userId="getShowChat.showChat ? getShowChat.chatUserId : null"
ref="chat"
@toggle-user-search="showUserSearch = !showUserSearch"
@toggle-user-search="toggleSearch"
:show-room="showRoom"
/>
</client-only>
@ -28,11 +30,15 @@ export default {
},
data() {
return {
showUserSearch: false,
showSearch: false,
}
},
mounted() {
this.showChat({ showChat: false, roomID: null })
this.showChat({ showChat: false, chatUserId: null, groupId: null })
this.openFromQuery()
},
watch: {
'$route.query': 'openFromQuery',
},
computed: {
...mapGetters({
@ -43,12 +49,74 @@ export default {
...mapMutations({
showChat: 'chat/SET_OPEN_CHAT',
}),
addChatRoom(userID) {
this.$refs.chat.newRoom(userID)
toggleSearch() {
this.showSearch = !this.showSearch
if (this.showSearch) {
this.$nextTick(() => {
this.$refs.searchPanel?.$el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
},
openFromQuery() {
const { userId, groupId } = this.$route.query
if (!userId && !groupId) return
// Wait for client-only chat component to be available
const tryOpen = () => {
if (this.$refs.chat) {
if (groupId) {
this.$refs.chat.newGroupRoom(groupId)
} else if (userId) {
this.$refs.chat.newRoom(userId)
}
// Clean query params from URL
this.$router.replace({ path: '/chat', query: {} })
} else {
setTimeout(tryOpen, 100)
}
}
tryOpen()
},
addChatRoom(user) {
this.$refs.chat.newRoom(user)
},
addGroupChatRoom(groupId) {
this.$refs.chat.newGroupRoom(groupId)
},
showRoom(roomId) {
this.showChat({ showChat: true, roomID: roomId })
this.showChat({ showChat: true, chatUserId: roomId })
},
},
}
</script>
<style lang="scss">
@media (max-width: 768px) {
.layout-default:has(.chat-page) {
> .ds-container {
max-width: 100% !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
.main-container {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.chat-page {
padding-top: var(--header-height, 66px);
height: 100dvh;
overflow: hidden;
display: flex;
flex-direction: column;
> * {
flex-shrink: 0;
}
> :last-child {
flex: 1;
min-height: 0;
}
}
}
}
</style>

View File

@ -377,6 +377,43 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!---->
</div>
<button
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
data-appearance="outline"
data-original-title="null"
data-variant="primary"
type="button"
>
<span
class="inline-flex items-center gap-2"
>
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
/>
<path
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
/>
</svg>
</span>
</span>
chat.groupChatButton.label
</span>
</button>
</div>
<hr />
@ -1274,6 +1311,8 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!---->
</div>
<!---->
</div>
<hr />
@ -1783,6 +1822,8 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!---->
</div>
<!---->
</div>
<hr />
@ -2432,6 +2473,43 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!---->
</div>
<button
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
data-appearance="outline"
data-original-title="null"
data-variant="primary"
type="button"
>
<span
class="inline-flex items-center gap-2"
>
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
/>
<path
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
/>
</svg>
</span>
</span>
chat.groupChatButton.label
</span>
</button>
</div>
<hr />
@ -3468,6 +3546,43 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!---->
</div>
<button
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
data-appearance="outline"
data-original-title="null"
data-variant="primary"
type="button"
>
<span
class="inline-flex items-center gap-2"
>
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
/>
<path
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
/>
</svg>
</span>
</span>
chat.groupChatButton.label
</span>
</button>
</div>
<hr />
@ -4324,6 +4439,8 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!---->
</div>
<!---->
</div>
<hr />
@ -5127,6 +5244,8 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!---->
</div>
<!---->
</div>
<hr />
@ -6043,6 +6162,43 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!---->
</div>
<button
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
data-appearance="outline"
data-original-title="null"
data-variant="primary"
type="button"
>
<span
class="inline-flex items-center gap-2"
>
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
/>
<path
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
/>
</svg>
</span>
</span>
chat.groupChatButton.label
</span>
</button>
</div>
<hr />
@ -7106,6 +7262,43 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<!---->
</div>
<button
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
data-appearance="outline"
data-original-title="null"
data-variant="primary"
type="button"
>
<span
class="inline-flex items-center gap-2"
>
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
/>
<path
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
/>
</svg>
</span>
</span>
chat.groupChatButton.label
</span>
</button>
</div>
<hr />
@ -8152,6 +8345,43 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<!---->
</div>
<button
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
data-appearance="outline"
data-original-title="null"
data-variant="primary"
type="button"
>
<span
class="inline-flex items-center gap-2"
>
<span
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
>
<span
aria-hidden="true"
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
>
<svg
fill="currentColor"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
/>
<path
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
/>
</svg>
</span>
</span>
chat.groupChatButton.label
</span>
</button>
</div>
<hr />

View File

@ -80,6 +80,21 @@
:loading="$apollo.loading"
@update="updateJoinLeave"
/>
<!-- Group chat -->
<os-button
v-if="isGroupMemberNonePending"
variant="primary"
appearance="outline"
full-width
v-tooltip="{
content: $t('chat.groupChatButton.tooltip', { name: groupName }),
placement: 'bottom-start',
}"
@click="showOrChangeGroupChat(group.id)"
>
<template #icon><os-icon :icon="icons.chatBubble" /></template>
{{ $t('chat.groupChatButton.label') }}
</os-button>
</div>
<hr />
<div class="ds-mt-small ds-mb-small">
@ -308,7 +323,7 @@ import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
import ProfileList from '~/components/features/ProfileList/ProfileList'
import SortCategories from '~/mixins/sortCategoriesMixin.js'
import { mapGetters } from 'vuex'
import { mapGetters, mapMutations } from 'vuex'
import GetCategories from '~/mixins/getCategoriesMixin.js'
// import SocialMedia from '~/components/SocialMedia/SocialMedia'
// import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation'
@ -374,6 +389,7 @@ export default {
computed: {
...mapGetters({
currentUser: 'auth/user',
getShowChat: 'chat/showChat',
}),
groupName() {
const { name } = this.group || {}
@ -440,6 +456,15 @@ export default {
},
},
methods: {
...mapMutations({
showChat: 'chat/SET_OPEN_CHAT',
}),
showOrChangeGroupChat(groupId) {
if (this.getShowChat.showChat) {
this.showChat({ showChat: false, chatUserId: null, groupId: null })
}
this.showChat({ showChat: true, chatUserId: null, groupId })
},
// handleTab(tab) {
// if (this.tabActive !== tab) {
// this.tabActive = tab

View File

@ -577,11 +577,11 @@ export default {
if (type === 'following') this.followingCount = count
if (type === 'followedBy') this.followedByCount = count
},
async showOrChangeChat(roomID) {
showOrChangeChat(userId) {
if (this.getShowChat.showChat) {
await this.showChat({ showChat: false, roomID: null })
this.showChat({ showChat: false, chatUserId: null })
}
await this.showChat({ showChat: true, roomID })
this.showChat({ showChat: true, chatUserId: userId })
},
},
apollo: {

View File

@ -1,7 +1,8 @@
export const state = () => {
return {
showChat: false,
roomID: null,
chatUserId: null,
groupId: null,
unreadRoomCount: 0,
}
}
@ -9,23 +10,18 @@ export const state = () => {
export const mutations = {
SET_OPEN_CHAT(state, ctx) {
state.showChat = ctx.showChat || false
state.roomID = ctx.roomID || null
state.chatUserId = ctx.chatUserId || null
state.groupId = ctx.groupId || null
},
UPDATE_ROOM_COUNT(state, count) {
state.unreadRoomCount = count
},
UPDATE_ROOM_ID(state, roomid) {
state.roomId = roomid || null
},
}
export const getters = {
showChat(state) {
return state
},
roomID(state) {
return state
},
unreadRoomCount(state) {
return state.unreadRoomCount
},