diff --git a/backend/src/constants/subscriptions.ts b/backend/src/constants/subscriptions.ts index ec3e79e63..d65286f23 100644 --- a/backend/src/constants/subscriptions.ts +++ b/backend/src/constants/subscriptions.ts @@ -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' diff --git a/backend/src/db/migrations/20260326120000-seen-to-has-not-seen.ts b/backend/src/db/migrations/20260326120000-seen-to-has-not-seen.ts new file mode 100644 index 000000000..a8b009917 --- /dev/null +++ b/backend/src/db/migrations/20260326120000-seen-to-has-not-seen.ts @@ -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() + } +} diff --git a/backend/src/db/migrations/20260326130000-delete-empty-dm-rooms.ts b/backend/src/db/migrations/20260326130000-delete-empty-dm-rooms.ts new file mode 100644 index 000000000..c84aa9d98 --- /dev/null +++ b/backend/src/db/migrations/20260326130000-delete-empty-dm-rooms.ts @@ -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 +} diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts index 4b14c6947..a5025d4c1 100644 --- a/backend/src/db/seed.ts +++ b/backend/src/db/seed.ts @@ -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) diff --git a/backend/src/db/types/Message.ts b/backend/src/db/types/Message.ts index 7835bedcf..82845d7f1 100644 --- a/backend/src/db/types/Message.ts +++ b/backend/src/db/types/Message.ts @@ -7,7 +7,6 @@ export interface MessageDbProperties { id: string indexId: number saved: boolean - seen: boolean } export type Message = Node diff --git a/backend/src/graphql/queries/messaging/CreateRoom.gql b/backend/src/graphql/queries/messaging/CreateGroupRoom.gql similarity index 50% rename from backend/src/graphql/queries/messaging/CreateRoom.gql rename to backend/src/graphql/queries/messaging/CreateGroupRoom.gql index 045435d85..caf7c8993 100644 --- a/backend/src/graphql/queries/messaging/CreateRoom.gql +++ b/backend/src/graphql/queries/messaging/CreateGroupRoom.gql @@ -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 - } } } } diff --git a/backend/src/graphql/queries/messaging/CreateMessage.gql b/backend/src/graphql/queries/messaging/CreateMessage.gql index d379306e1..886871ecd 100644 --- a/backend/src/graphql/queries/messaging/CreateMessage.gql +++ b/backend/src/graphql/queries/messaging/CreateMessage.gql @@ -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 diff --git a/backend/src/graphql/queries/messaging/Message.gql b/backend/src/graphql/queries/messaging/Message.gql index 119f6978c..fce8d99a7 100644 --- a/backend/src/graphql/queries/messaging/Message.gql +++ b/backend/src/graphql/queries/messaging/Message.gql @@ -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 diff --git a/backend/src/graphql/queries/messaging/Room.gql b/backend/src/graphql/queries/messaging/Room.gql index 1d66f862a..b9f8dbccd 100644 --- a/backend/src/graphql/queries/messaging/Room.gql +++ b/backend/src/graphql/queries/messaging/Room.gql @@ -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 diff --git a/backend/src/graphql/resolvers/attachments/attachments.spec.ts b/backend/src/graphql/resolvers/attachments/attachments.spec.ts index 8e576647a..748804d2b 100644 --- a/backend/src/graphql/resolvers/attachments/attachments.spec.ts +++ b/backend/src/graphql/resolvers/attachments/attachments.spec.ts @@ -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', }, }) diff --git a/backend/src/graphql/resolvers/groups.ts b/backend/src/graphql/resolvers/groups.ts index 219dfa126..344ce52d8 100644 --- a/backend/src/graphql/resolvers/groups.ts +++ b/backend/src/graphql/resolvers/groups.ts @@ -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 }) } diff --git a/backend/src/graphql/resolvers/messages.spec.ts b/backend/src/graphql/resolvers/messages.spec.ts index 427a04ba2..291c71697 100644 --- a/backend/src/graphql/resolvers/messages.spec.ts +++ b/backend/src/graphql/resolvers/messages.spec.ts @@ -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') + }) + }) }) diff --git a/backend/src/graphql/resolvers/messages.ts b/backend/src/graphql/resolvers/messages.ts index 0ec5cb9a1..209f1d44a 100644 --- a/backend/src/graphql/resolvers/messages.ts +++ b/backend/src/graphql/resolvers/messages.ts @@ -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() diff --git a/backend/src/graphql/resolvers/rooms.spec.ts b/backend/src/graphql/resolvers/rooms.spec.ts index a08d55b79..9b9341ef6 100644 --- a/backend/src/graphql/resolvers/rooms.spec.ts +++ b/backend/src/graphql/resolvers/rooms.spec.ts @@ -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) + }) }) diff --git a/backend/src/graphql/resolvers/rooms.ts b/backend/src/graphql/resolvers/rooms.ts index 060eccf34..5693fde79 100644 --- a/backend/src/graphql/resolvers/rooms.ts +++ b/backend/src/graphql/resolvers/rooms.ts @@ -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(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)', + }, }), }, } diff --git a/backend/src/graphql/resolvers/searches.ts b/backend/src/graphql/resolvers/searches.ts index 2f1a74662..514a0f0f5 100644 --- a/backend/src/graphql/resolvers/searches.ts +++ b/backend/src/graphql/resolvers/searches.ts @@ -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 diff --git a/backend/src/graphql/types/type/Message.gql b/backend/src/graphql/types/type/Message.gql index 553754d47..1a5fbc1ca 100644 --- a/backend/src/graphql/types/type/Message.gql +++ b/backend/src/graphql/types/type/Message.gql @@ -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 } diff --git a/backend/src/graphql/types/type/Room.gql b/backend/src/graphql/types/type/Room.gql index 64697fa37..eef490098 100644 --- a/backend/src/graphql/types/type/Room.gql +++ b/backend/src/graphql/types/type/Room.gql @@ -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 } diff --git a/backend/src/graphql/types/type/Search.gql b/backend/src/graphql/types/type/Search.gql index 5cb68e22d..5b76d3826 100644 --- a/backend/src/graphql/types/type/Search.gql +++ b/backend/src/graphql/types/type/Search.gql @@ -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]! } diff --git a/backend/src/middleware/chatMiddleware.ts b/backend/src/middleware/chatMiddleware.ts index 76a179d8f..ca41aee2e 100644 --- a/backend/src/middleware/chatMiddleware.ts +++ b/backend/src/middleware/chatMiddleware.ts @@ -58,7 +58,8 @@ export default { Message: messageProperties, }, Mutation: { - CreateRoom: roomProperties, + CreateGroupRoom: roomProperties, + CreateMessage: messageProperties, }, Subscription: { chatMessageAdded: messageProperties, diff --git a/backend/src/middleware/notifications/notificationsMiddleware.spec.ts b/backend/src/middleware/notifications/notificationsMiddleware.spec.ts index 76da25235..704bf30f9 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.spec.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.spec.ts @@ -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', () => { diff --git a/backend/src/middleware/notifications/notificationsMiddleware.ts b/backend/src/middleware/notifications/notificationsMiddleware.ts index 24c6273c6..045ab8849 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.ts @@ -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 }) } } diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index 3cbcdd0b5..f34649375 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -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, diff --git a/cypress/e2e/Chat.DirectMessage.feature b/cypress/e2e/Chat.DirectMessage.feature new file mode 100644 index 000000000..a2fa68ea4 --- /dev/null +++ b/cypress/e2e/Chat.DirectMessage.feature @@ -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 diff --git a/cypress/e2e/Chat.GroupChat.feature b/cypress/e2e/Chat.GroupChat.feature new file mode 100644 index 000000000..7784cdee7 --- /dev/null +++ b/cypress/e2e/Chat.GroupChat.feature @@ -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 diff --git a/cypress/e2e/Chat.ReadStatus.feature b/cypress/e2e/Chat.ReadStatus.feature new file mode 100644 index 000000000..7a78ffbe6 --- /dev/null +++ b/cypress/e2e/Chat.ReadStatus.feature @@ -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 diff --git a/cypress/e2e/Chat.Rooms.feature b/cypress/e2e/Chat.Rooms.feature new file mode 100644 index 000000000..33dd7298a --- /dev/null +++ b/cypress/e2e/Chat.Rooms.feature @@ -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 diff --git a/cypress/e2e/Chat.Search.feature b/cypress/e2e/Chat.Search.feature new file mode 100644 index 000000000..7e6e200db --- /dev/null +++ b/cypress/e2e/Chat.Search.feature @@ -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 diff --git a/cypress/support/step_definitions/Chat.DirectMessage/I_open_a_direct_message_with_{string}.js b/cypress/support/step_definitions/Chat.DirectMessage/I_open_a_direct_message_with_{string}.js new file mode 100644 index 000000000..1d471bcaf --- /dev/null +++ b/cypress/support/step_definitions/Chat.DirectMessage/I_open_a_direct_message_with_{string}.js @@ -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() +}) diff --git a/cypress/support/step_definitions/Chat.GroupChat/I_click_on_the_group_chat_button.js b/cypress/support/step_definitions/Chat.GroupChat/I_click_on_the_group_chat_button.js new file mode 100644 index 000000000..3cef01ef1 --- /dev/null +++ b/cypress/support/step_definitions/Chat.GroupChat/I_click_on_the_group_chat_button.js @@ -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() +}) diff --git a/cypress/support/step_definitions/Chat.GroupChat/I_open_the_group_chat_for_{string}.js b/cypress/support/step_definitions/Chat.GroupChat/I_open_the_group_chat_for_{string}.js new file mode 100644 index 000000000..0924f48a0 --- /dev/null +++ b/cypress/support/step_definitions/Chat.GroupChat/I_open_the_group_chat_for_{string}.js @@ -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() +}) diff --git a/cypress/support/step_definitions/Chat.GroupChat/I_see_the_group_chat_popup_with_name_{string}.js b/cypress/support/step_definitions/Chat.GroupChat/I_see_the_group_chat_popup_with_name_{string}.js new file mode 100644 index 000000000..3660c06c0 --- /dev/null +++ b/cypress/support/step_definitions/Chat.GroupChat/I_see_the_group_chat_popup_with_name_{string}.js @@ -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) +}) diff --git a/cypress/support/step_definitions/Chat.Rooms/I_see_{string}_at_position_{int}_in_the_room_list.js b/cypress/support/step_definitions/Chat.Rooms/I_see_{string}_at_position_{int}_in_the_room_list.js new file mode 100644 index 000000000..dad9948cd --- /dev/null +++ b/cypress/support/step_definitions/Chat.Rooms/I_see_{string}_at_position_{int}_in_the_room_list.js @@ -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) +}) diff --git a/cypress/support/step_definitions/Chat.Search/I_click_the_add_chat_button.js b/cypress/support/step_definitions/Chat.Search/I_click_the_add_chat_button.js new file mode 100644 index 000000000..a6dcc4e80 --- /dev/null +++ b/cypress/support/step_definitions/Chat.Search/I_click_the_add_chat_button.js @@ -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() +}) diff --git a/cypress/support/step_definitions/Chat.Search/I_search_for_{string}_in_the_chat_search.js b/cypress/support/step_definitions/Chat.Search/I_search_for_{string}_in_the_chat_search.js new file mode 100644 index 000000000..7ac7ae33b --- /dev/null +++ b/cypress/support/step_definitions/Chat.Search/I_search_for_{string}_in_the_chat_search.js @@ -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) +}) diff --git a/cypress/support/step_definitions/Chat.Search/I_see_no_chat_search_results.js b/cypress/support/step_definitions/Chat.Search/I_see_no_chat_search_results.js new file mode 100644 index 000000000..dcde156be --- /dev/null +++ b/cypress/support/step_definitions/Chat.Search/I_see_no_chat_search_results.js @@ -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') +}) diff --git a/cypress/support/step_definitions/Chat.Search/I_see_{string}_in_the_chat_search_results.js b/cypress/support/step_definitions/Chat.Search/I_see_{string}_in_the_chat_search_results.js new file mode 100644 index 000000000..744cd21ef --- /dev/null +++ b/cypress/support/step_definitions/Chat.Search/I_see_{string}_in_the_chat_search_results.js @@ -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) +}) diff --git a/cypress/support/step_definitions/Chat.Search/I_select_{string}_from_the_chat_search_results.js b/cypress/support/step_definitions/Chat.Search/I_select_{string}_from_the_chat_search_results.js new file mode 100644 index 000000000..805198aea --- /dev/null +++ b/cypress/support/step_definitions/Chat.Search/I_select_{string}_from_the_chat_search_results.js @@ -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() +}) diff --git a/cypress/support/step_definitions/common/I_click_on_the_room_{string}.js b/cypress/support/step_definitions/common/I_click_on_the_room_{string}.js new file mode 100644 index 000000000..2873cfca2 --- /dev/null +++ b/cypress/support/step_definitions/common/I_click_on_the_room_{string}.js @@ -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() +}) diff --git a/cypress/support/step_definitions/common/I_see_the_chat_room_with_{string}.js b/cypress/support/step_definitions/common/I_see_the_chat_room_with_{string}.js new file mode 100644 index 000000000..bc4cd5f2a --- /dev/null +++ b/cypress/support/step_definitions/common/I_see_the_chat_room_with_{string}.js @@ -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) +}) diff --git a/cypress/support/step_definitions/common/I_see_the_message_{string}_in_the_chat.js b/cypress/support/step_definitions/common/I_see_the_message_{string}_in_the_chat.js new file mode 100644 index 000000000..1aa358f45 --- /dev/null +++ b/cypress/support/step_definitions/common/I_see_the_message_{string}_in_the_chat.js @@ -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) +}) diff --git a/cypress/support/step_definitions/Chat.Notification/I_see_{int}_unread_chat_message_in_the_header.js b/cypress/support/step_definitions/common/I_see_{int}_unread_chat_message_in_the_header.js similarity index 100% rename from cypress/support/step_definitions/Chat.Notification/I_see_{int}_unread_chat_message_in_the_header.js rename to cypress/support/step_definitions/common/I_see_{int}_unread_chat_message_in_the_header.js diff --git a/cypress/support/step_definitions/common/I_send_the_message_{string}_in_the_chat.js b/cypress/support/step_definitions/common/I_send_the_message_{string}_in_the_chat.js new file mode 100644 index 000000000..06339397b --- /dev/null +++ b/cypress/support/step_definitions/common/I_send_the_message_{string}_in_the_chat.js @@ -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}`) +}) diff --git a/cypress/support/step_definitions/common/the_following_{string}_are_in_the_database.js b/cypress/support/step_definitions/common/the_following_{string}_are_in_the_database.js index b9639494e..e7e5fc36a 100644 --- a/cypress/support/step_definitions/common/the_following_{string}_are_in_the_database.js +++ b/cypress/support/step_definitions/common/the_following_{string}_are_in_the_database.js @@ -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 diff --git a/cypress/support/step_definitions/common/{string}_is_a_member_of_group_{string}.js b/cypress/support/step_definitions/common/{string}_is_a_member_of_group_{string}.js new file mode 100644 index 000000000..782643e83 --- /dev/null +++ b/cypress/support/step_definitions/common/{string}_is_a_member_of_group_{string}.js @@ -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 }, + ) + }) +}) diff --git a/cypress/support/step_definitions/common/{string}_opens_the_group_chat_for_{string}.js b/cypress/support/step_definitions/common/{string}_opens_the_group_chat_for_{string}.js new file mode 100644 index 000000000..20160bbc0 --- /dev/null +++ b/cypress/support/step_definitions/common/{string}_opens_the_group_chat_for_{string}.js @@ -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 }) + }) + }) +}) diff --git a/cypress/support/step_definitions/Chat.Notification/{string}_sends_a_chat_message_{string}_to_{string}.js b/cypress/support/step_definitions/common/{string}_sends_a_chat_message_{string}_to_{string}.js similarity index 63% rename from cypress/support/step_definitions/Chat.Notification/{string}_sends_a_chat_message_{string}_to_{string}.js rename to cypress/support/step_definitions/common/{string}_sends_a_chat_message_{string}_to_{string}.js index 6f079dbaa..711994bb4 100644 --- a/cypress/support/step_definitions/Chat.Notification/{string}_sends_a_chat_message_{string}_to_{string}.js +++ b/cypress/support/step_definitions/common/{string}_sends_a_chat_message_{string}_to_{string}.js @@ -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 }) }) }) }, diff --git a/cypress/support/step_definitions/common/{string}_sends_a_group_chat_message_{string}_to_{string}.js b/cypress/support/step_definitions/common/{string}_sends_a_group_chat_message_{string}_to_{string}.js new file mode 100644 index 000000000..f76bf6fa4 --- /dev/null +++ b/cypress/support/step_definitions/common/{string}_sends_a_group_chat_message_{string}_to_{string}.js @@ -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 }) + }) + }) + }) + }, +) diff --git a/webapp/components/Chat/AddChatRoomByUserSearch.spec.js b/webapp/components/Chat/AddChatRoomByUserSearch.spec.js new file mode 100644 index 000000000..9f0293480 --- /dev/null +++ b/webapp/components/Chat/AddChatRoomByUserSearch.spec.js @@ -0,0 +1,215 @@ +import { mount } from '@vue/test-utils' +import AddChatRoomByUserSearch from './AddChatRoomByUserSearch.vue' + +const localVue = global.localVue + +const stubs = { + 'os-button': { + template: + '', + }, + 'os-icon': { template: '' }, + 'os-badge': { template: '' }, + 'profile-avatar': { template: '
' }, + 'ocelot-select': { + template: + '
', + 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() + }) + }) +}) diff --git a/webapp/components/Chat/AddChatRoomByUserSearch.vue b/webapp/components/Chat/AddChatRoomByUserSearch.vue index 53b6afc27..ed1c33c73 100644 --- a/webapp/components/Chat/AddChatRoomByUserSearch.vue +++ b/webapp/components/Chat/AddChatRoomByUserSearch.vue @@ -9,7 +9,7 @@ circle size="sm" :aria-label="$t('actions.close')" - @click="closeUserSearch" + @click="closeSearch" > @@ -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; +} diff --git a/webapp/components/Chat/Chat.spec.js b/webapp/components/Chat/Chat.spec.js new file mode 100644 index 000000000..075fc71f5 --- /dev/null +++ b/webapp/components/Chat/Chat.spec.js @@ -0,0 +1,1019 @@ +import { mount } from '@vue/test-utils' +import Vuex from 'vuex' +import Chat from './Chat.vue' + +const localVue = global.localVue + +// Stub the web component to avoid shadow DOM issues in tests +const stubs = { + 'vue-advanced-chat': { + template: + '
', + props: [ + 'rooms', + 'messages', + 'messagesLoaded', + 'roomsLoaded', + 'currentUserId', + 'roomId', + 'height', + 'autoScroll', + ], + }, + 'nuxt-link': { template: '', props: ['to'] }, + 'os-button': { template: '' }, + 'os-icon': { template: '' }, + 'profile-avatar': { template: '
' }, +} + +const mockRoom = (overrides = {}) => ({ + id: 'room-1', + roomId: 'room-1', + roomName: 'Test Room', + avatar: null, + isGroupRoom: false, + lastMessageAt: '2026-01-01T00:00:00Z', + createdAt: '2026-01-01T00:00:00Z', + unreadCount: 0, + messagesUntilOldestUnread: 0, + groupProfile: null, + index: '2026-01-01T00:00:00Z', + lastMessage: { content: 'hello' }, + users: [ + { _id: 'current-user', id: 'current-user', username: 'Me', avatar: null }, + { _id: 'other-user', id: 'other-user', username: 'Other', avatar: null }, + ], + ...overrides, +}) + +const mockMessage = (overrides = {}) => ({ + _id: 'msg-1', + id: 'msg-1', + indexId: 0, + content: 'Hello', + senderId: 'other-user', + username: 'Other', + avatar: null, + date: '2026-01-01T00:00:00Z', + saved: true, + distributed: false, + seen: false, + files: [], + room: { id: 'room-1' }, + ...overrides, +}) + +describe('Chat.vue', () => { + let wrapper, mocks, store, subscriptionHandlers + + beforeEach(() => { + subscriptionHandlers = {} + + mocks = { + $t: jest.fn((key) => key), + $i18n: { locale: () => 'en' }, + $toast: { success: jest.fn(), error: jest.fn() }, + $router: { push: jest.fn() }, + $apollo: { + query: jest.fn().mockResolvedValue({ data: { Room: [], Message: [] } }), + mutate: jest.fn().mockResolvedValue({ data: {} }), + subscribe: jest.fn().mockImplementation(() => ({ + subscribe: jest.fn((handlers) => { + // Capture subscription handlers for testing + const key = + mocks.$apollo.subscribe.mock.calls.length <= 1 + ? 'chatMessageAdded' + : 'chatMessageStatusUpdated' + subscriptionHandlers[key] = handlers + return { unsubscribe: jest.fn() } + }), + })), + }, + } + + store = new Vuex.Store({ + modules: { + auth: { + namespaced: true, + getters: { + user: () => ({ id: 'current-user', name: 'Me', avatar: null }), + }, + }, + chat: { + namespaced: true, + state: { unreadRoomCount: 0 }, + mutations: { + UPDATE_ROOM_COUNT: jest.fn(), + }, + }, + }, + }) + }) + + const Wrapper = (propsData = {}) => { + return mount(Chat, { + propsData, + localVue, + store, + mocks, + stubs, + }) + } + + describe('mount', () => { + it('renders without errors', () => { + wrapper = Wrapper() + expect(wrapper.exists()).toBe(true) + }) + + it('fetches rooms on mount', () => { + mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } }) + wrapper = Wrapper() + expect(mocks.$apollo.query).toHaveBeenCalled() + }) + + it('subscribes to chatMessageAdded and chatMessageStatusUpdated', () => { + wrapper = Wrapper() + expect(mocks.$apollo.subscribe).toHaveBeenCalledTimes(2) + }) + }) + + describe('mergeMessages', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('adds new messages sorted by indexId', () => { + const messages = [ + mockMessage({ _id: 'msg-2', id: 'msg-2', indexId: 2, content: 'Second' }), + mockMessage({ _id: 'msg-1', id: 'msg-1', indexId: 0, content: 'First' }), + mockMessage({ _id: 'msg-3', id: 'msg-3', indexId: 1, content: 'Middle' }), + ] + wrapper.vm.mergeMessages(messages) + expect(wrapper.vm.messages.map((m) => m.indexId)).toEqual([0, 1, 2]) + }) + + it('tracks unseen incoming messages', () => { + const messages = [ + mockMessage({ id: 'unseen-1', indexId: 0, seen: false, senderId: 'other-user' }), + mockMessage({ id: 'seen-1', indexId: 1, seen: true, senderId: 'other-user' }), + mockMessage({ id: 'own-1', indexId: 2, seen: false, senderId: 'current-user' }), + ] + wrapper.vm.mergeMessages(messages) + expect(wrapper.vm.unseenMessageIds.has('unseen-1')).toBe(true) + expect(wrapper.vm.unseenMessageIds.has('seen-1')).toBe(false) + expect(wrapper.vm.unseenMessageIds.has('own-1')).toBe(false) + }) + + it('deduplicates by indexId', () => { + wrapper.vm.mergeMessages([mockMessage({ indexId: 0, content: 'original' })]) + wrapper.vm.mergeMessages([mockMessage({ indexId: 0, content: 'duplicate' })]) + expect(wrapper.vm.messages).toHaveLength(1) + }) + }) + + describe('prepareMessage', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('formats date and timestamp', () => { + const msg = mockMessage({ date: '2026-03-30T14:30:00Z' }) + const prepared = wrapper.vm.prepareMessage(msg) + expect(prepared._rawDate).toBe('2026-03-30T14:30:00Z') + expect(prepared.timestamp).toBeDefined() + expect(prepared.date).toBeDefined() + }) + + it('normalizes avatar', () => { + const msg = mockMessage({ avatar: { w320: 'http://img.jpg' } }) + const prepared = wrapper.vm.prepareMessage(msg) + expect(prepared.avatar).toBe('http://img.jpg') + expect(prepared._originalAvatar).toBe('http://img.jpg') + }) + + it('handles null avatar', () => { + const msg = mockMessage({ avatar: null }) + const prepared = wrapper.vm.prepareMessage(msg) + expect(prepared.avatar).toBeNull() + }) + }) + + describe('applyAvatarsOnList', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('shows avatar only on last message of each sender chain', () => { + const messages = [ + { senderId: 'a', _originalAvatar: 'a.jpg', avatar: null }, + { senderId: 'a', _originalAvatar: 'a.jpg', avatar: null }, + { senderId: 'b', _originalAvatar: 'b.jpg', avatar: null }, + { senderId: 'a', _originalAvatar: 'a.jpg', avatar: null }, + ] + wrapper.vm.applyAvatarsOnList(messages) + expect(messages.map((m) => m.avatar)).toEqual([null, 'a.jpg', 'b.jpg', 'a.jpg']) + }) + + it('handles single message', () => { + const messages = [{ senderId: 'a', _originalAvatar: 'a.jpg', avatar: null }] + wrapper.vm.applyAvatarsOnList(messages) + expect(messages[0].avatar).toBe('a.jpg') + }) + }) + + describe('replaceLocalMessage', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.vm.messages = [ + { + _id: 'local-1', + id: undefined, + isUploading: true, + files: [], + distributed: false, + seen: false, + }, + ] + }) + + it('stores id mapping', () => { + wrapper.vm.replaceLocalMessage('local-1', { id: 'server-1', _id: 'server-1' }) + expect(wrapper.vm._localToServerIds['server-1']).toBe('local-1') + }) + + it('clears upload state and updates files', () => { + const serverFiles = [{ url: 'http://cdn/file.jpg', name: 'file', type: 'image/jpeg' }] + wrapper.vm.replaceLocalMessage('local-1', { id: 'server-1', files: serverFiles }) + expect(wrapper.vm.messages[0].isUploading).toBe(false) + expect(wrapper.vm.messages[0].files).toEqual(serverFiles) + }) + + it('applies pending status updates', () => { + wrapper.vm.pendingStatusUpdates['server-1'] = { distributed: true } + wrapper.vm.replaceLocalMessage('local-1', { id: 'server-1' }) + expect(wrapper.vm.messages[0].distributed).toBe(true) + expect(wrapper.vm.pendingStatusUpdates['server-1']).toBeUndefined() + }) + }) + + describe('handleMessageStatusUpdated', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.vm.selectedRoom = mockRoom() + wrapper.vm.messages = [ + { _id: 'msg-1', id: 'msg-1', distributed: false, seen: false }, + { _id: 'msg-2', id: 'msg-2', distributed: false, seen: false }, + ] + wrapper.vm.rooms = [mockRoom()] + }) + + it('updates distributed status on messages', () => { + subscriptionHandlers.chatMessageStatusUpdated?.next?.({ + data: { + chatMessageStatusUpdated: { + roomId: 'room-1', + messageIds: ['msg-1'], + status: 'distributed', + }, + }, + }) + expect(wrapper.vm.messages[0].distributed).toBe(true) + expect(wrapper.vm.messages[1].distributed).toBe(false) + }) + + it('updates seen status on messages', () => { + subscriptionHandlers.chatMessageStatusUpdated?.next?.({ + data: { + chatMessageStatusUpdated: { + roomId: 'room-1', + messageIds: ['msg-1', 'msg-2'], + status: 'seen', + }, + }, + }) + expect(wrapper.vm.messages[0].seen).toBe(true) + expect(wrapper.vm.messages[1].seen).toBe(true) + }) + + it('queues updates for messages not yet known', () => { + subscriptionHandlers.chatMessageStatusUpdated?.next?.({ + data: { + chatMessageStatusUpdated: { + roomId: 'room-1', + messageIds: ['unknown-id'], + status: 'distributed', + }, + }, + }) + expect(wrapper.vm.pendingStatusUpdates['unknown-id']).toEqual({ distributed: true }) + }) + + it('resolves messages via local-to-server id mapping', () => { + wrapper.vm._localToServerIds = { 'server-1': 'msg-1' } + subscriptionHandlers.chatMessageStatusUpdated?.next?.({ + data: { + chatMessageStatusUpdated: { + roomId: 'room-1', + messageIds: ['server-1'], + status: 'distributed', + }, + }, + }) + expect(wrapper.vm.messages[0].distributed).toBe(true) + }) + + it('updates room lastMessage status', () => { + wrapper.vm.rooms = [ + mockRoom({ lastMessage: { id: 'msg-1', content: 'hi', seen: false, distributed: false } }), + ] + subscriptionHandlers.chatMessageStatusUpdated?.next?.({ + data: { + chatMessageStatusUpdated: { + roomId: 'room-1', + messageIds: ['msg-1'], + status: 'seen', + }, + }, + }) + expect(wrapper.vm.rooms[0].lastMessage.seen).toBe(true) + }) + }) + + describe('markAsSeen', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.vm.selectedRoom = mockRoom({ unreadCount: 3 }) + wrapper.vm.rooms = [mockRoom({ unreadCount: 3 })] + wrapper.vm.messages = [ + { _id: 'msg-1', id: 'msg-1', seen: false }, + { _id: 'msg-2', id: 'msg-2', seen: false }, + ] + mocks.$apollo.mutate.mockResolvedValue({ data: { MarkMessagesAsSeen: true } }) + mocks.$apollo.query.mockResolvedValue({ data: { UnreadRooms: 1 } }) + }) + + it('updates messages locally to seen', () => { + wrapper.vm.markAsSeen(['msg-1']) + expect(wrapper.vm.messages[0].seen).toBe(true) + expect(wrapper.vm.messages[1].seen).toBe(false) + }) + + it('decrements room unread count', () => { + wrapper.vm.markAsSeen(['msg-1', 'msg-2']) + expect(wrapper.vm.rooms[0].unreadCount).toBe(1) + }) + + it('does not go below zero', () => { + wrapper.vm.rooms = [mockRoom({ unreadCount: 1 })] + wrapper.vm.markAsSeen(['msg-1', 'msg-2']) + expect(wrapper.vm.rooms[0].unreadCount).toBe(0) + }) + + it('calls MarkMessagesAsSeen mutation', () => { + wrapper.vm.markAsSeen(['msg-1']) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { messageIds: ['msg-1'] }, + }), + ) + }) + + it('skips if no messageIds', () => { + wrapper.vm.markAsSeen([]) + expect(mocks.$apollo.mutate).not.toHaveBeenCalled() + }) + }) + + describe('addSocketMessage', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.vm.messages = [] + }) + + it('adds a new message', () => { + wrapper.vm.addSocketMessage(mockMessage({ _id: 'new-1', id: 'new-1', indexId: 0 })) + expect(wrapper.vm.messages).toHaveLength(1) + }) + + it('deduplicates by _id', () => { + wrapper.vm.addSocketMessage(mockMessage({ _id: 'dup', id: 'dup', indexId: 0 })) + wrapper.vm.addSocketMessage(mockMessage({ _id: 'dup', id: 'dup', indexId: 0 })) + expect(wrapper.vm.messages).toHaveLength(1) + }) + + it('deduplicates by id', () => { + wrapper.vm.messages = [{ _id: 'local', id: 'server-1', indexId: 0 }] + wrapper.vm.addSocketMessage(mockMessage({ _id: 'server-1', id: 'server-1', indexId: 0 })) + expect(wrapper.vm.messages).toHaveLength(1) + }) + + it('tracks unseen messages from other users', () => { + wrapper.vm.addSocketMessage( + mockMessage({ id: 'unseen', senderId: 'other-user', seen: false }), + ) + expect(wrapper.vm.unseenMessageIds.has('unseen')).toBe(true) + }) + + it('does not track own messages as unseen', () => { + wrapper.vm.addSocketMessage(mockMessage({ id: 'own', senderId: 'current-user', seen: false })) + expect(wrapper.vm.unseenMessageIds.has('own')).toBe(false) + }) + }) + + describe('bringRoomToTopAndSelect', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.vm.rooms = [ + mockRoom({ id: 'room-1', roomId: 'room-1' }), + mockRoom({ id: 'room-2', roomId: 'room-2' }), + mockRoom({ id: 'room-3', roomId: 'room-3' }), + ] + }) + + it('moves room to first position', () => { + const room = wrapper.vm.rooms[2] + wrapper.vm.bringRoomToTopAndSelect(room) + expect(wrapper.vm.rooms[0].id).toBe('room-3') + }) + + it('sets index to current timestamp', () => { + const before = new Date().toISOString() + const room = wrapper.vm.rooms[1] + wrapper.vm.bringRoomToTopAndSelect(room) + expect(wrapper.vm.rooms[0].index >= before).toBe(true) + }) + + it('removes duplicate from original position', () => { + const room = wrapper.vm.rooms[1] + wrapper.vm.bringRoomToTopAndSelect(room) + expect(wrapper.vm.rooms).toHaveLength(3) + }) + }) + + describe('fixRoomObject', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('normalizes a basic DM room', () => { + const raw = { + id: 'r1', + roomId: 'r1', + roomName: 'User', + isGroupRoom: false, + createdAt: '2026-01-01', + lastMessage: null, + users: [ + { id: 'current-user', name: 'Me', avatar: null }, + { id: 'other', name: 'Other', avatar: { w320: 'img.jpg' } }, + ], + } + const fixed = wrapper.vm.fixRoomObject(raw) + expect(fixed.isGroupRoom).toBe(false) + expect(fixed.groupProfile).toBeNull() + expect(fixed.users[1].avatar).toBe('img.jpg') + expect(fixed.avatar).toBe('img.jpg') + }) + + it('normalizes a group room', () => { + const raw = { + id: 'r2', + roomId: 'r2', + roomName: 'Group', + isGroupRoom: true, + createdAt: '2026-01-01', + lastMessage: null, + group: { id: 'g1', slug: 'test-group', name: 'Test Group', avatar: { w320: 'group.jpg' } }, + users: [{ id: 'current-user', name: 'Me', avatar: null }], + } + const fixed = wrapper.vm.fixRoomObject(raw) + expect(fixed.isGroupRoom).toBe(true) + expect(fixed.groupProfile).toEqual({ + id: 'g1', + slug: 'test-group', + name: 'Test Group', + avatar: { w320: 'group.jpg' }, + }) + expect(fixed.avatar).toBe('group.jpg') + }) + + it('truncates lastMessage content to 30 chars', () => { + const raw = { + id: 'r3', + roomId: 'r3', + roomName: 'Room', + isGroupRoom: false, + createdAt: '2026-01-01', + lastMessage: { content: 'A'.repeat(100), date: '2026-01-01' }, + users: [{ id: 'current-user', name: 'Me', avatar: null }], + } + const fixed = wrapper.vm.fixRoomObject(raw) + expect(fixed.lastMessage.content).toHaveLength(30) + }) + }) + + describe('chatMessageAdded', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.vm.rooms = [mockRoom()] + wrapper.vm.selectedRoom = mockRoom() + wrapper.vm.messages = [] + }) + + it('updates room lastMessage and moves to top', async () => { + await wrapper.vm.chatMessageAdded({ + data: { + chatMessageAdded: mockMessage({ + senderId: 'other-user', + content: 'New message', + room: { id: 'room-1' }, + }), + }, + }) + expect(wrapper.vm.rooms[0].lastMessage.content).toBe('New message') + }) + + it('adds message to current room if from other user', async () => { + await wrapper.vm.chatMessageAdded({ + data: { + chatMessageAdded: mockMessage({ + _id: 'new-msg', + id: 'new-msg', + indexId: 5, + senderId: 'other-user', + room: { id: 'room-1' }, + }), + }, + }) + expect(wrapper.vm.messages).toHaveLength(1) + }) + + it('does not add own messages to chat (handled via mutation response)', async () => { + await wrapper.vm.chatMessageAdded({ + data: { + chatMessageAdded: mockMessage({ + senderId: 'current-user', + room: { id: 'room-1' }, + }), + }, + }) + expect(wrapper.vm.messages).toHaveLength(0) + }) + + it('increments unreadCount for non-current rooms', async () => { + wrapper.vm.selectedRoom = mockRoom({ id: 'other-room' }) + await wrapper.vm.chatMessageAdded({ + data: { + chatMessageAdded: mockMessage({ + senderId: 'other-user', + room: { id: 'room-1' }, + }), + }, + }) + expect(wrapper.vm.rooms[0].unreadCount).toBe(1) + }) + }) + + describe('fetchMessages', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.vm.rooms = [] + wrapper.vm.messages = [] + }) + + it('loads one page by default', async () => { + const room = mockRoom({ id: 'r-default', roomId: 'r-default' }) + mocks.$apollo.query.mockResolvedValue({ data: { Message: [] } }) + await wrapper.vm.fetchMessages({ room }) + const messageCall = mocks.$apollo.query.mock.calls.find( + ([arg]) => arg.variables?.roomId === 'r-default', + ) + expect(messageCall[0].variables.first).toBe(wrapper.vm.messagePageSize) + }) + + it('uses beforeIndex cursor for subsequent loads', async () => { + const room = mockRoom({ id: 'r-cursor', roomId: 'r-cursor' }) + const messages = Array.from({ length: 20 }, (_, i) => + mockMessage({ indexId: i, id: `m${i}`, _id: `m${i}` }), + ) + mocks.$apollo.query.mockResolvedValue({ data: { Message: messages } }) + await wrapper.vm.fetchMessages({ room }) + // Second fetch should use cursor + mocks.$apollo.query.mockResolvedValue({ data: { Message: [] } }) + await wrapper.vm.fetchMessages({ room }) + const calls = mocks.$apollo.query.mock.calls.filter( + ([arg]) => arg.variables?.roomId === 'r-cursor', + ) + expect(calls[1][0].variables.beforeIndex).toBe(0) + }) + + it('sets messagesLoaded when server returns fewer than pageSize', async () => { + const room = mockRoom({ id: 'r-loaded', roomId: 'r-loaded' }) + mocks.$apollo.query.mockResolvedValue({ + data: { Message: [mockMessage({ indexId: 0 })] }, + }) + await wrapper.vm.fetchMessages({ room }) + expect(wrapper.vm.messagesLoaded).toBe(true) + }) + + it('tracks unseen messages from server response', async () => { + const room = mockRoom({ id: 'r-unseen', roomId: 'r-unseen' }) + mocks.$apollo.query.mockResolvedValue({ + data: { + Message: [ + mockMessage({ id: 'unseen-1', indexId: 0, seen: false, senderId: 'other-user' }), + ], + }, + }) + await wrapper.vm.fetchMessages({ room }) + expect(wrapper.vm.unseenMessageIds.has('unseen-1')).toBe(true) + }) + }) + + describe('sendMessage', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.vm.selectedRoom = mockRoom() + wrapper.vm.rooms = [mockRoom()] + wrapper.vm.messages = [] + mocks.$apollo.mutate.mockResolvedValue({ + data: { + CreateMessage: { + id: 'server-msg-1', + _id: 'server-msg-1', + indexId: 0, + content: 'Hello', + senderId: 'current-user', + saved: true, + distributed: false, + seen: false, + files: [], + room: { id: 'room-1' }, + }, + }, + }) + }) + + it('adds local message immediately', async () => { + const promise = wrapper.vm.sendMessage({ roomId: 'room-1', content: 'Hello' }) + expect(wrapper.vm.messages).toHaveLength(1) + expect(wrapper.vm.messages[0].content).toBe('Hello') + expect(wrapper.vm.messages[0].isUploading).toBe(true) + await promise + }) + + it('local message has saved=true and seen=false', async () => { + const promise = wrapper.vm.sendMessage({ roomId: 'room-1', content: 'Hi' }) + expect(wrapper.vm.messages[0].saved).toBe(true) + expect(wrapper.vm.messages[0].seen).toBe(false) + await promise + }) + + it('calls CreateMessage mutation', async () => { + await wrapper.vm.sendMessage({ roomId: 'room-1', content: 'Hello' }) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ content: 'Hello', roomId: 'room-1' }), + }), + ) + }) + + it('replaces local message with server response', async () => { + await wrapper.vm.sendMessage({ roomId: 'room-1', content: 'Hello' }) + expect(wrapper.vm._localToServerIds['server-msg-1']).toBeDefined() + }) + + it('uses userId for virtual rooms', async () => { + wrapper.vm.rooms = [ + mockRoom({ id: 'temp-user1', roomId: 'temp-user1', _virtualUserId: 'user1' }), + ] + mocks.$apollo.mutate.mockResolvedValue({ + data: { CreateMessage: { id: 's1', room: { id: 'real-room' } } }, + }) + mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } }) + await wrapper.vm.sendMessage({ roomId: 'temp-user1', content: 'Hi' }) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ userId: 'user1' }), + }), + ) + }) + + it('handles files', async () => { + global.URL.createObjectURL = jest.fn().mockReturnValue('blob:test') + const blob = new Blob(['test'], { type: 'text/plain' }) + await wrapper.vm.sendMessage({ + roomId: 'room-1', + content: 'with file', + files: [{ blob, name: 'test', type: 'text/plain', extension: 'txt' }], + }) + expect(mocks.$apollo.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + files: expect.arrayContaining([ + expect.objectContaining({ name: 'test', type: 'text/plain' }), + ]), + }), + }), + ) + delete global.URL.createObjectURL + }) + + it('shows toast on error', async () => { + mocks.$apollo.mutate.mockRejectedValue(new Error('Send failed')) + await wrapper.vm.sendMessage({ roomId: 'room-1', content: 'Hello' }) + expect(mocks.$toast.error).toHaveBeenCalledWith('Send failed') + }) + + it('moves room to top and scrolls', async () => { + const promise = wrapper.vm.sendMessage({ roomId: 'room-1', content: 'Hello' }) + expect(wrapper.vm.rooms[0].id).toBe('room-1') + await promise + }) + }) + + describe('newRoom', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.vm.rooms = [] + }) + + it('selects existing local room', async () => { + wrapper.vm.rooms = [mockRoom({ users: [{ id: 'current-user' }, { id: 'target-user' }] })] + await wrapper.vm.newRoom('target-user') + expect(wrapper.vm.activeRoomId).toBe('room-1') + }) + + it('fetches room from server if not local', async () => { + const serverRoom = mockRoom({ + id: 'server-room', + roomId: 'server-room', + users: [ + { id: 'current-user', name: 'Me' }, + { id: 'target', name: 'Target' }, + ], + }) + mocks.$apollo.query.mockResolvedValue({ data: { Room: [serverRoom] } }) + await wrapper.vm.newRoom('target') + expect(wrapper.vm.rooms[0].id).toBe('server-room') + }) + + it('creates virtual room when server has none', async () => { + mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } }) + await wrapper.vm.newRoom({ id: 'new-user', name: 'New User' }) + expect(wrapper.vm.rooms[0].id).toBe('temp-new-user') + expect(wrapper.vm.rooms[0]._virtualUserId).toBe('new-user') + }) + + it('accepts user object with avatar', async () => { + mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } }) + await wrapper.vm.newRoom({ id: 'u1', name: 'User', avatar: { w320: 'img.jpg' } }) + expect(wrapper.vm.rooms[0].avatar).toBe('img.jpg') + }) + + it('handles server error gracefully', async () => { + mocks.$apollo.query.mockRejectedValue(new Error('network')) + await wrapper.vm.newRoom({ id: 'u1', name: 'User' }) + expect(wrapper.vm.rooms[0].id).toBe('temp-u1') + }) + }) + + describe('newGroupRoom', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.vm.rooms = [] + }) + + it('selects existing local group room', async () => { + wrapper.vm.rooms = [ + mockRoom({ + id: 'gr-1', + roomId: 'gr-1', + isGroupRoom: true, + groupProfile: { id: 'group-1' }, + }), + ] + await wrapper.vm.newGroupRoom('group-1') + expect(wrapper.vm.activeRoomId).toBe('gr-1') + }) + + it('fetches group room from server', async () => { + const serverRoom = mockRoom({ + id: 'gr-server', + roomId: 'gr-server', + isGroupRoom: true, + group: { id: 'g1', name: 'G', slug: 'g' }, + users: [{ id: 'current-user', name: 'Me' }], + }) + mocks.$apollo.query.mockResolvedValue({ data: { Room: [serverRoom] } }) + await wrapper.vm.newGroupRoom('g1') + expect(wrapper.vm.rooms[0].id).toBe('gr-server') + }) + + it('creates group room via mutation when not found', async () => { + mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } }) + mocks.$apollo.mutate.mockResolvedValue({ + data: { + CreateGroupRoom: mockRoom({ + id: 'new-gr', + roomId: 'new-gr', + isGroupRoom: true, + group: { id: 'g1', name: 'G', slug: 'g' }, + users: [{ id: 'current-user', name: 'Me' }], + }), + }, + }) + await wrapper.vm.newGroupRoom('g1') + expect(wrapper.vm.rooms[0].id).toBe('new-gr') + }) + + it('shows toast on creation error', async () => { + mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } }) + mocks.$apollo.mutate.mockRejectedValue(new Error('Create failed')) + await wrapper.vm.newGroupRoom('g1') + expect(mocks.$toast.error).toHaveBeenCalledWith('Create failed') + }) + }) + + describe('singleRoom mode', () => { + it('mounts with groupId and calls newGroupRoom', () => { + mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } }) + mocks.$apollo.mutate.mockResolvedValue({ + data: { + CreateGroupRoom: mockRoom({ + id: 'gr', + roomId: 'gr', + isGroupRoom: true, + group: { id: 'g1', name: 'G' }, + users: [{ id: 'current-user', name: 'Me' }], + }), + }, + }) + wrapper = Wrapper({ singleRoom: true, groupId: 'g1' }) + // newGroupRoom first queries the server for existing room + expect(mocks.$apollo.query).toHaveBeenCalled() + }) + + it('mounts with userId and calls newRoom', () => { + mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } }) + wrapper = Wrapper({ singleRoom: true, userId: 'u1' }) + expect(mocks.$apollo.query).toHaveBeenCalled() + }) + }) + + describe('watchers', () => { + it('calls newGroupRoom when groupId changes in singleRoom mode', async () => { + mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } }) + mocks.$apollo.mutate.mockResolvedValue({ + data: { + CreateGroupRoom: mockRoom({ + id: 'gr', + roomId: 'gr', + isGroupRoom: true, + group: { id: 'g2', name: 'G2' }, + users: [{ id: 'current-user', name: 'Me' }], + }), + }, + }) + wrapper = Wrapper({ singleRoom: true, groupId: 'g1' }) + await wrapper.setProps({ groupId: 'g2' }) + expect(mocks.$apollo.mutate).toHaveBeenCalled() + }) + + it('calls newRoom when userId changes in singleRoom mode', async () => { + mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } }) + wrapper = Wrapper({ singleRoom: true, userId: 'u1' }) + await wrapper.setProps({ userId: 'u2' }) + // newRoom is called, which queries the server + expect(mocks.$apollo.query).toHaveBeenCalled() + }) + }) + + describe('openFile', () => { + beforeEach(() => { + wrapper = Wrapper() + global.fetch = jest.fn().mockResolvedValue({ + blob: jest.fn().mockResolvedValue(new Blob(['test'])), + }) + global.URL.createObjectURL = jest.fn().mockReturnValue('blob:test') + }) + + afterEach(() => { + delete global.fetch + }) + + it('skips null file', async () => { + await wrapper.vm.openFile(null) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('skips file without url', async () => { + await wrapper.vm.openFile({ type: 'image/png' }) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('skips video files', async () => { + await wrapper.vm.openFile({ url: 'http://test.mp4', type: 'video/mp4' }) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('downloads non-video files', async () => { + const clickMock = jest.fn() + jest.spyOn(document, 'createElement').mockReturnValue({ + href: '', + download: '', + style: {}, + click: clickMock, + }) + jest.spyOn(document.body, 'appendChild').mockImplementation(() => {}) + jest.spyOn(document.body, 'removeChild').mockImplementation(() => {}) + await wrapper.vm.openFile({ url: 'http://test.jpg', name: 'img', type: 'image/jpeg' }) + expect(global.fetch).toHaveBeenCalledWith('http://test.jpg', expect.any(Object)) + expect(clickMock).toHaveBeenCalled() + document.createElement.mockRestore() + document.body.appendChild.mockRestore() + document.body.removeChild.mockRestore() + }) + }) + + describe('redirectToUserProfile', () => { + it('navigates to user profile', () => { + wrapper = Wrapper() + wrapper.vm.redirectToUserProfile({ user: { id: 'u1', name: 'John Doe' } }) + expect(mocks.$router.push).toHaveBeenCalledWith({ + path: '/profile/u1/john-doe', + }) + }) + }) + + describe('roomHeaderLink computed', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('returns null when no room selected', () => { + wrapper.vm.selectedRoom = null + expect(wrapper.vm.roomHeaderLink).toBeNull() + }) + + it('returns group link for group rooms', () => { + wrapper.vm.selectedRoom = mockRoom({ + isGroupRoom: true, + groupProfile: { id: 'g1', slug: 'test-group' }, + }) + expect(wrapper.vm.roomHeaderLink).toBe('/groups/g1/test-group') + }) + + it('returns profile link for DM rooms', () => { + wrapper.vm.selectedRoom = mockRoom({ + isGroupRoom: false, + users: [ + { id: 'current-user', name: 'Me' }, + { id: 'other', name: 'Other User' }, + ], + }) + expect(wrapper.vm.roomHeaderLink).toBe('/profile/other/other-user') + }) + }) + + describe('fetchRooms', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.vm.rooms = [] + }) + + it('deduplicates rooms', async () => { + const room = mockRoom({ lastMessageAt: '2026-01-01' }) + mocks.$apollo.query.mockResolvedValue({ data: { Room: [room] } }) + await wrapper.vm.fetchRooms({}) + await wrapper.vm.fetchRooms({}) + // Same room loaded twice but only appears once + expect(wrapper.vm.rooms.filter((r) => r.id === 'room-1')).toHaveLength(1) + }) + + it('sets roomsLoaded when fewer than pageSize', async () => { + mocks.$apollo.query.mockResolvedValue({ data: { Room: [mockRoom()] } }) + await wrapper.vm.fetchRooms({}) + expect(wrapper.vm.roomsLoaded).toBe(true) + }) + + it('handles error', async () => { + mocks.$apollo.query.mockRejectedValue(new Error('Fetch failed')) + await wrapper.vm.fetchRooms({}) + expect(mocks.$toast.error).toHaveBeenCalledWith('Fetch failed') + }) + }) + + describe('beforeDestroy', () => { + it('unsubscribes from subscriptions', () => { + wrapper = Wrapper() + const subs = wrapper.vm._subscriptions + expect(subs).toHaveLength(2) + wrapper.destroy() + subs.forEach((s) => { + expect(s.unsubscribe).toHaveBeenCalled() + }) + }) + }) +}) diff --git a/webapp/components/Chat/Chat.vue b/webapp/components/Chat/Chat.vue index b1dd9b8b3..dd20059e6 100644 --- a/webapp/components/Chat/Chat.vue +++ b/webapp/components/Chat/Chat.vue @@ -17,7 +17,7 @@ show-files="true" show-audio="true" capture-files="true" - :height="'calc(100dvh - 190px)'" + :height="chatHeight" :styles="JSON.stringify(computedChatStyle)" :show-footer="true" :responsive-breakpoint="responsiveBreakpoint" @@ -27,7 +27,6 @@ @fetch-messages="fetchMessages($event.detail[0])" @fetch-more-rooms="fetchRooms" @add-room="toggleUserSearch" - @show-demo-options="showDemoOptions = $event" @open-user-tag="redirectToUserProfile($event.detail[0])" @open-file="openFile($event.detail[0].file.file)" > @@ -36,7 +35,7 @@
-
-
- {{ getInitialsName(selectedRoom.roomName) }} -
+ + +
+
+ {{ getInitialsName(selectedRoom.roomName) }} +
+ +
-
-
-

{{ $t('chat.transmitting') }}

+
+
+ + {{ selectedRoom ? selectedRoom.roomName : '' }} + + {{ selectedRoom ? selectedRoom.roomName : '' }}
+
@@ -107,11 +134,15 @@ diff --git a/webapp/locales/de.json b/webapp/locales/de.json index cb1420fe7..f3bd476bd 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -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": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 214e316f1..c27a29964 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -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": { diff --git a/webapp/locales/es.json b/webapp/locales/es.json index 70b0be6f4..ddfdb729f 100644 --- a/webapp/locales/es.json +++ b/webapp/locales/es.json @@ -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": { diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json index ab149bfe9..df35c6d10 100644 --- a/webapp/locales/fr.json +++ b/webapp/locales/fr.json @@ -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": { diff --git a/webapp/locales/it.json b/webapp/locales/it.json index 0fb3fcebc..4a3f418ae 100644 --- a/webapp/locales/it.json +++ b/webapp/locales/it.json @@ -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": { diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json index ff9e448cf..9f230e161 100644 --- a/webapp/locales/nl.json +++ b/webapp/locales/nl.json @@ -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": { diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json index 47703b047..7904bf9dc 100644 --- a/webapp/locales/pl.json +++ b/webapp/locales/pl.json @@ -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": { diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index 52ec9894d..f45bf419c 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -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": { diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json index 84f1b2711..428064a46 100644 --- a/webapp/locales/ru.json +++ b/webapp/locales/ru.json @@ -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": { diff --git a/webapp/locales/sq.json b/webapp/locales/sq.json index 76dcad00b..f4c2dfe33 100644 --- a/webapp/locales/sq.json +++ b/webapp/locales/sq.json @@ -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": { diff --git a/webapp/locales/uk.json b/webapp/locales/uk.json index cafdc8e33..877ffb4c6 100644 --- a/webapp/locales/uk.json +++ b/webapp/locales/uk.json @@ -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": { diff --git a/webapp/pages/chat.vue b/webapp/pages/chat.vue index ffeb6a800..c0c6a8fbc 100644 --- a/webapp/pages/chat.vue +++ b/webapp/pages/chat.vue @@ -1,15 +1,17 @@