mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2026-04-03 08:05:33 +00:00
feat(webapp): group chat (#9439)
This commit is contained in:
parent
1be90b4976
commit
e931d6e03b
@ -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'
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -7,7 +7,6 @@ export interface MessageDbProperties {
|
||||
id: string
|
||||
indexId: number
|
||||
saved: boolean
|
||||
seen: boolean
|
||||
}
|
||||
|
||||
export type Message = Node<Integer, MessageDbProperties>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -15,10 +15,11 @@ import Resolver from './helpers/Resolver'
|
||||
export const getUnreadRoomsCount = async (userId, session) => {
|
||||
return session.readTransaction(async (transaction) => {
|
||||
const unreadRoomsCypher = `
|
||||
MATCH (user:User { id: $userId })-[:CHATS_IN]->(room:Room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User)
|
||||
WHERE NOT sender.id = $userId AND NOT message.seen
|
||||
AND NOT (user)-[:BLOCKED]->(sender)
|
||||
AND NOT (user)-[:MUTED]->(sender)
|
||||
MATCH (user:User { id: $userId })-[:HAS_NOT_SEEN]->(message:Message)-[:INSIDE]->(room:Room)<-[:CHATS_IN]-(user)
|
||||
OPTIONAL MATCH (message)<-[:CREATED]-(sender:User)
|
||||
WHERE (user)-[:BLOCKED]->(sender) OR (user)-[:MUTED]->(sender)
|
||||
WITH room, message, sender
|
||||
WHERE sender IS NULL
|
||||
RETURN toString(COUNT(DISTINCT room)) AS count
|
||||
`
|
||||
const unreadRoomsTxResponse = await transaction.run(unreadRoomsCypher, { userId })
|
||||
@ -26,24 +27,101 @@ export const getUnreadRoomsCount = async (userId, session) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const roomCountUpdatedFilter = (payload, variables, context) => {
|
||||
return payload.userId === context.user?.id
|
||||
}
|
||||
|
||||
export default {
|
||||
Subscription: {
|
||||
roomCountUpdated: {
|
||||
subscribe: withFilter(
|
||||
(_, __, context) => context.pubsub.asyncIterator(ROOM_COUNT_UPDATED),
|
||||
(payload, variables, context) => {
|
||||
return payload.userId === context.user?.id
|
||||
},
|
||||
roomCountUpdatedFilter,
|
||||
),
|
||||
},
|
||||
},
|
||||
Query: {
|
||||
Room: async (object, params, context, resolveInfo) => {
|
||||
if (!params.filter) params.filter = {}
|
||||
params.filter.users_some = {
|
||||
id: context.user.id,
|
||||
// Single room lookup by userId or groupId
|
||||
if (params.userId || params.groupId) {
|
||||
const session = context.driver.session()
|
||||
try {
|
||||
const cypher = params.groupId
|
||||
? `
|
||||
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room)-[:ROOM_FOR]->(group:Group { id: $groupId })
|
||||
RETURN room { .* } AS room
|
||||
`
|
||||
: `
|
||||
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room)<-[:CHATS_IN]-(user:User { id: $userId })
|
||||
WHERE NOT (room)-[:ROOM_FOR]->(:Group)
|
||||
RETURN room { .* } AS room
|
||||
`
|
||||
const result = await session.readTransaction(async (transaction) => {
|
||||
return transaction.run(cypher, {
|
||||
currentUserId: context.user.id,
|
||||
userId: params.userId || null,
|
||||
groupId: params.groupId || null,
|
||||
})
|
||||
})
|
||||
const rooms = result.records.map((record) => record.get('room'))
|
||||
if (rooms.length === 0) return []
|
||||
// Re-query via neo4jgraphql to get all computed fields
|
||||
delete params.userId
|
||||
delete params.groupId
|
||||
params.filter = { users_some: { id: context.user.id } }
|
||||
params.id = rooms[0].id
|
||||
return neo4jgraphql(object, params, context, resolveInfo)
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Single room lookup by id
|
||||
if (params.id) {
|
||||
if (!params.filter) params.filter = {}
|
||||
params.filter.users_some = { id: context.user.id }
|
||||
return neo4jgraphql(object, params, context, resolveInfo)
|
||||
}
|
||||
|
||||
// Room list with cursor-based pagination sorted by latest activity
|
||||
const session = context.driver.session()
|
||||
try {
|
||||
const first = params.first || 10
|
||||
const before = params.before || null
|
||||
const result = await session.readTransaction(async (transaction) => {
|
||||
const cypher = `
|
||||
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room)
|
||||
WITH room, COALESCE(room.lastMessageAt, room.createdAt) AS sortDate
|
||||
${before ? 'WHERE sortDate < $before' : ''}
|
||||
RETURN room.id AS id
|
||||
ORDER BY sortDate DESC
|
||||
LIMIT toInteger($first)
|
||||
`
|
||||
return transaction.run(cypher, {
|
||||
currentUserId: context.user.id,
|
||||
first,
|
||||
before,
|
||||
})
|
||||
})
|
||||
const roomIds: string[] = result.records.map((record) => record.get('id') as string)
|
||||
if (roomIds.length === 0) return []
|
||||
// Batch query via neo4jgraphql with id_in filter (avoids N+1)
|
||||
const roomParams = {
|
||||
filter: {
|
||||
id_in: roomIds,
|
||||
users_some: { id: context.user.id },
|
||||
},
|
||||
}
|
||||
const rooms = await neo4jgraphql(object, roomParams, context, resolveInfo)
|
||||
// Preserve the sort order from the cursor query
|
||||
const orderMap = new Map<string, number>(roomIds.map((id, i) => [id, i]))
|
||||
return (rooms || []).sort(
|
||||
(a: { id: string }, b: { id: string }) =>
|
||||
(orderMap.get(a.id) || 0) - (orderMap.get(b.id) || 0),
|
||||
)
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
return neo4jgraphql(object, params, context, resolveInfo)
|
||||
},
|
||||
UnreadRooms: async (_object, _params, context, _resolveInfo) => {
|
||||
const {
|
||||
@ -59,46 +137,51 @@ export default {
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
CreateRoom: async (_parent, params, context, _resolveInfo) => {
|
||||
const { userId } = params
|
||||
CreateGroupRoom: async (_parent, params, context, _resolveInfo) => {
|
||||
const { groupId } = params
|
||||
const {
|
||||
user: { id: currentUserId },
|
||||
} = context
|
||||
if (userId === currentUserId) {
|
||||
throw new Error('Cannot create a room with self')
|
||||
}
|
||||
const session = context.driver.session()
|
||||
try {
|
||||
const room = await session.writeTransaction(async (transaction) => {
|
||||
const createRoomCypher = `
|
||||
MATCH (currentUser:User { id: $currentUserId })
|
||||
MATCH (user:User { id: $userId })
|
||||
MERGE (currentUser)-[:CHATS_IN]->(room:Room)<-[:CHATS_IN]-(user)
|
||||
// Step 1: Create/merge the room and add all active group members to it
|
||||
const createGroupRoomCypher = `
|
||||
MATCH (currentUser:User { id: $currentUserId })-[membership:MEMBER_OF]->(group:Group { id: $groupId })
|
||||
WHERE membership.role IN ['usual', 'admin', 'owner']
|
||||
MERGE (room:Room)-[:ROOM_FOR]->(group)
|
||||
ON CREATE SET
|
||||
room.createdAt = toString(datetime()),
|
||||
room.id = apoc.create.uuid()
|
||||
WITH room, user, currentUser
|
||||
OPTIONAL MATCH (room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User)
|
||||
WHERE NOT sender.id = $currentUserId AND NOT message.seen
|
||||
WITH room, user, currentUser, message,
|
||||
user.name AS roomName
|
||||
WITH room, group, currentUser
|
||||
MATCH (member:User)-[m:MEMBER_OF]->(group)
|
||||
WHERE m.role IN ['usual', 'admin', 'owner']
|
||||
MERGE (member)-[:CHATS_IN]->(room)
|
||||
WITH room, group, currentUser, collect(properties(member)) AS members
|
||||
OPTIONAL MATCH (currentUser)-[:HAS_NOT_SEEN]->(message:Message)-[:INSIDE]->(room)
|
||||
WITH room, group, members, COUNT(DISTINCT message) AS unread
|
||||
OPTIONAL MATCH (group)-[:AVATAR_IMAGE]->(groupImg:Image)
|
||||
RETURN room {
|
||||
.*,
|
||||
users: [properties(currentUser), properties(user)],
|
||||
roomName: roomName,
|
||||
unreadCount: toString(COUNT(DISTINCT message))
|
||||
roomName: group.name,
|
||||
avatar: groupImg.url,
|
||||
isGroupRoom: true,
|
||||
group: properties(group),
|
||||
users: members,
|
||||
unreadCount: toString(unread)
|
||||
}
|
||||
`
|
||||
const createRoomTxResponse = await transaction.run(createRoomCypher, {
|
||||
userId,
|
||||
const createGroupRoomTxResponse = await transaction.run(createGroupRoomCypher, {
|
||||
groupId,
|
||||
currentUserId,
|
||||
})
|
||||
const [room] = createRoomTxResponse.records.map((record) => record.get('room'))
|
||||
const [room] = createGroupRoomTxResponse.records.map((record) => record.get('room'))
|
||||
return room
|
||||
})
|
||||
if (room) {
|
||||
room.roomId = room.id
|
||||
if (!room) {
|
||||
throw new Error('Could not create group room. User may not be a member of the group.')
|
||||
}
|
||||
room.roomId = room.id
|
||||
return room
|
||||
} finally {
|
||||
await session.close()
|
||||
@ -111,6 +194,9 @@ export default {
|
||||
hasMany: {
|
||||
users: '<-[:CHATS_IN]-(related:User)',
|
||||
},
|
||||
hasOne: {
|
||||
group: '-[:ROOM_FOR]->(related:Group)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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]!
|
||||
}
|
||||
|
||||
@ -58,7 +58,8 @@ export default {
|
||||
Message: messageProperties,
|
||||
},
|
||||
Mutation: {
|
||||
CreateRoom: roomProperties,
|
||||
CreateGroupRoom: roomProperties,
|
||||
CreateMessage: messageProperties,
|
||||
},
|
||||
Subscription: {
|
||||
chatMessageAdded: messageProperties,
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
24
cypress/e2e/Chat.DirectMessage.feature
Normal file
24
cypress/e2e/Chat.DirectMessage.feature
Normal file
@ -0,0 +1,24 @@
|
||||
Feature: Direct Messages
|
||||
As a user
|
||||
I want to send direct messages to other users
|
||||
So that I can have private conversations
|
||||
|
||||
Background:
|
||||
Given the following "users" are in the database:
|
||||
| slug | email | password | id | name | termsAndConditionsAgreedVersion |
|
||||
| alice | alice@example.org | 1234 | alice | Alice | 0.0.4 |
|
||||
| bob | bob@example.org | 1234 | bob | Bob | 0.0.4 |
|
||||
|
||||
Scenario: Send a direct message via chat page
|
||||
Given I am logged in as "alice"
|
||||
And I navigate to page "/chat"
|
||||
When I open a direct message with "bob"
|
||||
And I send the message "Hello Bob!" in the chat
|
||||
Then I see the message "Hello Bob!" in the chat
|
||||
|
||||
Scenario: Open a direct message via query parameter
|
||||
Given I am logged in as "alice"
|
||||
When I navigate to page "/chat?userId=bob"
|
||||
Then I see the chat room with "Bob"
|
||||
When I send the message "Hi from link!" in the chat
|
||||
Then I see the message "Hi from link!" in the chat
|
||||
37
cypress/e2e/Chat.GroupChat.feature
Normal file
37
cypress/e2e/Chat.GroupChat.feature
Normal file
@ -0,0 +1,37 @@
|
||||
Feature: Group Chat
|
||||
As a group member
|
||||
I want to chat with other group members in a group chat
|
||||
So that we can communicate within the group
|
||||
|
||||
Background:
|
||||
Given the following "users" are in the database:
|
||||
| slug | email | password | id | name | termsAndConditionsAgreedVersion |
|
||||
| alice | alice@example.org | 1234 | alice | Alice | 0.0.4 |
|
||||
| bob | bob@example.org | 4321 | bob | Bob | 0.0.4 |
|
||||
And the following "groups" are in the database:
|
||||
| id | name | slug | ownerId | groupType | description |
|
||||
| test-group | Test Group | test-group | alice | public | This is a test group for e2e testing of the group chat feature. It needs to be long enough to pass validation. |
|
||||
|
||||
Scenario: Open group chat from group profile
|
||||
Given "bob" is a member of group "test-group"
|
||||
And I am logged in as "bob"
|
||||
And I navigate to page "/groups/test-group/test-group"
|
||||
When I click on the group chat button
|
||||
Then I see the group chat popup with name "Test Group"
|
||||
|
||||
Scenario: Send a message in the group chat
|
||||
Given "bob" is a member of group "test-group"
|
||||
And I am logged in as "bob"
|
||||
And I navigate to page "/chat"
|
||||
When I open the group chat for "test-group"
|
||||
And I send the message "Hello Group!" in the chat
|
||||
Then I see the message "Hello Group!" in the chat
|
||||
|
||||
Scenario: Receive a group chat notification
|
||||
Given "bob" is a member of group "test-group"
|
||||
And "alice" opens the group chat for "test-group"
|
||||
And I am logged in as "bob"
|
||||
And I navigate to page "/"
|
||||
And I see no unread chat messages in the header
|
||||
When "alice" sends a group chat message "Hello everyone!" to "test-group"
|
||||
Then I see 1 unread chat message in the header
|
||||
31
cypress/e2e/Chat.ReadStatus.feature
Normal file
31
cypress/e2e/Chat.ReadStatus.feature
Normal file
@ -0,0 +1,31 @@
|
||||
Feature: Chat Read Status
|
||||
As a user
|
||||
I want messages to be marked as read when I view them
|
||||
So that I know which messages are new
|
||||
|
||||
Background:
|
||||
Given the following "users" are in the database:
|
||||
| slug | email | password | id | name | termsAndConditionsAgreedVersion |
|
||||
| alice | alice@example.org | 1234 | alice | Alice | 0.0.4 |
|
||||
| bob | bob@example.org | 1234 | bob | Bob | 0.0.4 |
|
||||
| charlie | charlie@example.org | 1234 | charlie | Charlie | 0.0.4 |
|
||||
|
||||
Scenario: Messages are marked as read when opening a chat room
|
||||
Given "alice" sends a chat message "Hey Bob!" to "bob"
|
||||
And I am logged in as "bob"
|
||||
And I navigate to page "/"
|
||||
And I see 1 unread chat message in the header
|
||||
When I navigate to page "/chat"
|
||||
And I click on the room "Alice"
|
||||
Then I see the message "Hey Bob!" in the chat
|
||||
And I see no unread chat messages in the header
|
||||
|
||||
Scenario: Notification badge decreases when reading one of multiple rooms
|
||||
Given "alice" sends a chat message "Hey Bob!" to "bob"
|
||||
And "charlie" sends a chat message "Hi Bob!" to "bob"
|
||||
And I am logged in as "bob"
|
||||
And I navigate to page "/"
|
||||
And I see 2 unread chat message in the header
|
||||
When I navigate to page "/chat"
|
||||
And I click on the room "Alice"
|
||||
Then I see 1 unread chat message in the header
|
||||
29
cypress/e2e/Chat.Rooms.feature
Normal file
29
cypress/e2e/Chat.Rooms.feature
Normal file
@ -0,0 +1,29 @@
|
||||
Feature: Chat Rooms
|
||||
As a user
|
||||
I want to manage and navigate between chat rooms
|
||||
So that I can keep track of my conversations
|
||||
|
||||
Background:
|
||||
Given the following "users" are in the database:
|
||||
| slug | email | password | id | name | termsAndConditionsAgreedVersion |
|
||||
| alice | alice@example.org | 1234 | alice | Alice | 0.0.4 |
|
||||
| bob | bob@example.org | 1234 | bob | Bob | 0.0.4 |
|
||||
| charlie | charlie@example.org | 1234 | charlie | Charlie | 0.0.4 |
|
||||
|
||||
Scenario: Switch between chat rooms
|
||||
Given "alice" sends a chat message "Hello Bob!" to "bob"
|
||||
And "charlie" sends a chat message "Hi Bob!" to "bob"
|
||||
And I am logged in as "bob"
|
||||
And I navigate to page "/chat"
|
||||
When I click on the room "Alice"
|
||||
Then I see the message "Hello Bob!" in the chat
|
||||
When I click on the room "Charlie"
|
||||
Then I see the message "Hi Bob!" in the chat
|
||||
|
||||
Scenario: Room list is sorted by latest activity
|
||||
Given "alice" sends a chat message "First message" to "bob"
|
||||
And "charlie" sends a chat message "Second message" to "bob"
|
||||
And I am logged in as "bob"
|
||||
And I navigate to page "/chat"
|
||||
Then I see "Charlie" at position 1 in the room list
|
||||
And I see "Alice" at position 2 in the room list
|
||||
39
cypress/e2e/Chat.Search.feature
Normal file
39
cypress/e2e/Chat.Search.feature
Normal file
@ -0,0 +1,39 @@
|
||||
Feature: Chat Search
|
||||
As a user
|
||||
I want to search for users and groups to start conversations
|
||||
So that I can easily find chat partners
|
||||
|
||||
Background:
|
||||
Given the following "users" are in the database:
|
||||
| slug | email | password | id | name | termsAndConditionsAgreedVersion |
|
||||
| alice | alice@example.org | 1234 | alice | Alice | 0.0.4 |
|
||||
| bob | bob@example.org | 1234 | bob | Bob | 0.0.4 |
|
||||
And the following "groups" are in the database:
|
||||
| id | name | slug | ownerId | groupType | description |
|
||||
| test-group | Test Group | test-group | alice | public | This is a test group for e2e testing of the chat search feature. It needs to be long enough to pass validation. |
|
||||
|
||||
Scenario: Search for a user and start a chat
|
||||
Given I am logged in as "alice"
|
||||
And I navigate to page "/chat"
|
||||
When I click the add chat button
|
||||
And I search for "bob" in the chat search
|
||||
Then I see "Bob" in the chat search results
|
||||
When I select "Bob" from the chat search results
|
||||
Then I see the chat room with "Bob"
|
||||
|
||||
Scenario: Search for a group and start a group chat
|
||||
Given "alice" is a member of group "test-group"
|
||||
And I am logged in as "alice"
|
||||
And I navigate to page "/chat"
|
||||
When I click the add chat button
|
||||
And I search for "test-group" in the chat search
|
||||
Then I see "Test Group" in the chat search results
|
||||
When I select "Test Group" from the chat search results
|
||||
Then I see the chat room with "Test Group"
|
||||
|
||||
Scenario: Search with less than 3 characters shows no results
|
||||
Given I am logged in as "alice"
|
||||
And I navigate to page "/chat"
|
||||
When I click the add chat button
|
||||
And I search for "bo" in the chat search
|
||||
Then I see no chat search results
|
||||
@ -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()
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
@ -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}`)
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
},
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
215
webapp/components/Chat/AddChatRoomByUserSearch.spec.js
Normal file
215
webapp/components/Chat/AddChatRoomByUserSearch.spec.js
Normal file
@ -0,0 +1,215 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AddChatRoomByUserSearch from './AddChatRoomByUserSearch.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const stubs = {
|
||||
'os-button': {
|
||||
template:
|
||||
'<button @click="$listeners.click && $listeners.click()"><slot /><slot name="icon" /></button>',
|
||||
},
|
||||
'os-icon': { template: '<span />' },
|
||||
'os-badge': { template: '<span><slot /></span>' },
|
||||
'profile-avatar': { template: '<div />' },
|
||||
'ocelot-select': {
|
||||
template:
|
||||
'<div class="ocelot-select"><slot /><slot name="option" :option="{ name: \'test\' }" /></div>',
|
||||
props: ['value', 'options', 'loading', 'filter', 'placeholder', 'noOptionsAvailable'],
|
||||
},
|
||||
}
|
||||
|
||||
describe('AddChatRoomByUserSearch.vue', () => {
|
||||
let wrapper, mocks
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$t: jest.fn((key) => key),
|
||||
$apollo: {
|
||||
queries: {
|
||||
searchChatTargets: { loading: false },
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(AddChatRoomByUserSearch, {
|
||||
localVue,
|
||||
mocks,
|
||||
stubs,
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
it('renders', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows search headline', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(mocks.$t).toHaveBeenCalledWith('chat.addRoomHeadline')
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSearch computed', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('returns false for empty query', () => {
|
||||
wrapper.vm.query = ''
|
||||
expect(wrapper.vm.startSearch).toBeFalsy()
|
||||
})
|
||||
|
||||
it('returns false for short query', () => {
|
||||
wrapper.vm.query = 'ab'
|
||||
expect(wrapper.vm.startSearch).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for query with 3+ characters', () => {
|
||||
wrapper.vm.query = 'abc'
|
||||
expect(wrapper.vm.startSearch).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleInput', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('sets query from event target', () => {
|
||||
wrapper.vm.handleInput({ target: { value: ' hello ' } })
|
||||
expect(wrapper.vm.query).toBe('hello')
|
||||
})
|
||||
|
||||
it('sets empty string when no target', () => {
|
||||
wrapper.vm.handleInput({})
|
||||
expect(wrapper.vm.query).toBe('')
|
||||
})
|
||||
|
||||
it('clears results when query is too short', () => {
|
||||
wrapper.vm.results = [{ id: '1' }]
|
||||
wrapper.vm.handleInput({ target: { value: 'ab' } })
|
||||
expect(wrapper.vm.results).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('onDelete', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('clears when value is empty', () => {
|
||||
wrapper.vm.query = 'test'
|
||||
wrapper.vm.results = [{ id: '1' }]
|
||||
wrapper.vm.onDelete({ target: { value: '' } })
|
||||
expect(wrapper.vm.query).toBe('')
|
||||
expect(wrapper.vm.results).toEqual([])
|
||||
})
|
||||
|
||||
it('calls handleInput when value is not empty', () => {
|
||||
wrapper.vm.onDelete({ target: { value: 'abc' } })
|
||||
expect(wrapper.vm.query).toBe('abc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('resets query and results', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.query = 'test'
|
||||
wrapper.vm.results = [{ id: '1' }]
|
||||
wrapper.vm.clear()
|
||||
expect(wrapper.vm.query).toBe('')
|
||||
expect(wrapper.vm.results).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('onBlur', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('clears query and results after timeout', () => {
|
||||
wrapper.vm.query = 'test'
|
||||
wrapper.vm.results = [{ id: '1' }]
|
||||
wrapper.vm.onBlur()
|
||||
expect(wrapper.vm.query).toBe('test')
|
||||
jest.advanceTimersByTime(200)
|
||||
expect(wrapper.vm.query).toBe('')
|
||||
expect(wrapper.vm.results).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSelect', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('ignores null', () => {
|
||||
wrapper.vm.onSelect(null)
|
||||
expect(wrapper.emitted('add-chat-room')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('ignores string', () => {
|
||||
wrapper.vm.onSelect('some string')
|
||||
expect(wrapper.emitted('add-chat-room')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('ignores items without __typename', () => {
|
||||
wrapper.vm.onSelect({ id: '1', name: 'Test' })
|
||||
expect(wrapper.emitted('add-chat-room')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('emits add-group-chat-room for Group items', async () => {
|
||||
wrapper.vm.onSelect({ id: 'g1', name: 'Group', __typename: 'Group' })
|
||||
expect(wrapper.emitted('add-group-chat-room')).toBeTruthy()
|
||||
expect(wrapper.emitted('add-group-chat-room')[0]).toEqual(['g1'])
|
||||
})
|
||||
|
||||
it('emits add-chat-room for User items', () => {
|
||||
wrapper.vm.onSelect({
|
||||
id: 'u1',
|
||||
name: 'User',
|
||||
slug: 'user',
|
||||
avatar: 'avatar.jpg',
|
||||
__typename: 'User',
|
||||
})
|
||||
expect(wrapper.emitted('add-chat-room')).toBeTruthy()
|
||||
expect(wrapper.emitted('add-chat-room')[0]).toEqual([
|
||||
{ id: 'u1', name: 'User', slug: 'user', avatar: 'avatar.jpg' },
|
||||
])
|
||||
})
|
||||
|
||||
it('emits close-user-search after selection', async () => {
|
||||
wrapper.vm.onSelect({ id: 'u1', name: 'User', __typename: 'User' })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('close-user-search')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeSearch', () => {
|
||||
it('emits close-user-search', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.closeSearch()
|
||||
expect(wrapper.emitted('close-user-search')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('beforeDestroy', () => {
|
||||
it('clears blur timeout', () => {
|
||||
jest.useFakeTimers()
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.onBlur()
|
||||
wrapper.destroy()
|
||||
jest.advanceTimersByTime(200)
|
||||
// No error thrown = timeout was cleared
|
||||
jest.useRealTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -9,7 +9,7 @@
|
||||
circle
|
||||
size="sm"
|
||||
:aria-label="$t('actions.close')"
|
||||
@click="closeUserSearch"
|
||||
@click="closeSearch"
|
||||
>
|
||||
<template #icon>
|
||||
<os-icon :icon="icons.close" />
|
||||
@ -18,57 +18,158 @@
|
||||
</div>
|
||||
<div class="ds-mb-small"></div>
|
||||
<div class="ds-mb-large">
|
||||
<select-user-search :id="id" ref="selectUserSearch" @select-user="selectUser" />
|
||||
<ocelot-select
|
||||
class="chat-search-combined"
|
||||
type="search"
|
||||
icon="search"
|
||||
label-prop="id"
|
||||
:value="selectedItem"
|
||||
id="chat-search-combined"
|
||||
:icon-right="null"
|
||||
:options="results"
|
||||
:loading="$apollo.queries.searchChatTargets.loading"
|
||||
:filter="(item) => item"
|
||||
:no-options-available="$t('chat.searchPlaceholder')"
|
||||
:auto-reset-search="true"
|
||||
:placeholder="$t('chat.searchPlaceholder')"
|
||||
@input.native="handleInput"
|
||||
@keyup.delete.native="onDelete"
|
||||
@keyup.esc.native="clear"
|
||||
@blur.capture.native="onBlur"
|
||||
@input="onSelect"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="chat-search-result-item">
|
||||
<profile-avatar :profile="option" size="small" />
|
||||
<div class="chat-search-result-info">
|
||||
<span class="chat-search-result-name">{{ option.name }}</span>
|
||||
<span class="chat-search-result-detail">
|
||||
{{ option.__typename === 'Group' ? `&${option.slug}` : `@${option.slug}` }}
|
||||
</span>
|
||||
</div>
|
||||
<os-badge size="sm" class="chat-search-result-badge">
|
||||
{{
|
||||
option.__typename === 'Group'
|
||||
? $t('chat.searchBadgeGroup')
|
||||
: $t('chat.searchBadgeUser')
|
||||
}}
|
||||
</os-badge>
|
||||
</div>
|
||||
</template>
|
||||
</ocelot-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
import { OsButton, OsIcon, OsBadge } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import SelectUserSearch from '~/components/generic/SelectUserSearch/SelectUserSearch'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { searchChatTargets } from '~/graphql/Search.js'
|
||||
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
|
||||
import OcelotSelect from '~/components/OcelotSelect/OcelotSelect.vue'
|
||||
|
||||
export default {
|
||||
name: 'AddChatRoomByUserSearch',
|
||||
components: {
|
||||
OsButton,
|
||||
OsIcon,
|
||||
SelectUserSearch,
|
||||
},
|
||||
props: {
|
||||
// chatRooms: {
|
||||
// type: Array,
|
||||
// default: [],
|
||||
// },
|
||||
OsBadge,
|
||||
ProfileAvatar,
|
||||
OcelotSelect,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
id: 'search-user-to-add-to-group',
|
||||
user: {},
|
||||
query: '',
|
||||
selectedItem: null,
|
||||
results: [],
|
||||
blurTimeout: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
startSearch() {
|
||||
return this.query && this.query.length >= 3
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearTimeout(this.blurTimeout)
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
},
|
||||
methods: {
|
||||
selectUser(user) {
|
||||
this.user = user
|
||||
// if (this.groupMembers.find((member) => member.id === this.user.id)) {
|
||||
// this.$toast.error(this.$t('group.errors.userAlreadyMember', { name: this.user.name }))
|
||||
// this.$refs.selectUserSearch.clear()
|
||||
// return
|
||||
// }
|
||||
this.$refs.selectUserSearch.clear()
|
||||
this.$emit('close-user-search')
|
||||
this.addChatRoom(this.user?.id)
|
||||
onBlur() {
|
||||
clearTimeout(this.blurTimeout)
|
||||
this.blurTimeout = setTimeout(() => {
|
||||
this.query = ''
|
||||
this.results = []
|
||||
}, 200)
|
||||
},
|
||||
async addChatRoom(userId) {
|
||||
this.$emit('add-chat-room', userId)
|
||||
handleInput(event) {
|
||||
this.query = event.target ? event.target.value.trim() : ''
|
||||
if (!this.startSearch) {
|
||||
this.results = []
|
||||
}
|
||||
},
|
||||
closeUserSearch() {
|
||||
onDelete(event) {
|
||||
const value = event.target ? event.target.value.trim() : ''
|
||||
if (isEmpty(value)) {
|
||||
this.clear()
|
||||
} else {
|
||||
this.handleInput(event)
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
this.query = ''
|
||||
this.results = []
|
||||
},
|
||||
onSelect(item) {
|
||||
if (!item || typeof item === 'string') return
|
||||
if (!item.__typename) return
|
||||
clearTimeout(this.blurTimeout)
|
||||
this.selectedItem = item
|
||||
if (item.__typename === 'Group') {
|
||||
this.$emit('add-group-chat-room', item.id)
|
||||
} else {
|
||||
this.$emit('add-chat-room', {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
avatar: item.avatar,
|
||||
})
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.$emit('close-user-search')
|
||||
})
|
||||
},
|
||||
closeSearch() {
|
||||
this.$emit('close-user-search')
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
searchChatTargets: {
|
||||
query() {
|
||||
return searchChatTargets
|
||||
},
|
||||
variables() {
|
||||
return {
|
||||
query: this.query,
|
||||
limit: 10,
|
||||
}
|
||||
},
|
||||
skip() {
|
||||
return !this.startSearch
|
||||
},
|
||||
update({ searchChatTargets }) {
|
||||
this.results = searchChatTargets.map((item) => ({
|
||||
...item,
|
||||
// Normalize Group name field (groupName alias → name)
|
||||
name: item.name || item.groupName,
|
||||
}))
|
||||
},
|
||||
fetchPolicy: 'no-cache',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -76,6 +177,7 @@ export default {
|
||||
.add-chat-room-by-user-search {
|
||||
background-color: white;
|
||||
padding: $space-base;
|
||||
scroll-margin-top: 7rem;
|
||||
}
|
||||
.ds-flex.headline {
|
||||
justify-content: space-between;
|
||||
@ -83,4 +185,37 @@ export default {
|
||||
.ds-flex.headline .close-button {
|
||||
margin-top: -2px;
|
||||
}
|
||||
.chat-search-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.chat-search-result-info {
|
||||
margin-left: $space-x-small;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-search-result-name {
|
||||
font-weight: $font-weight-bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chat-search-result-detail {
|
||||
font-size: $font-size-small;
|
||||
color: $text-color-soft;
|
||||
}
|
||||
.chat-search-result-badge {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
// Tailwind @layer utilities have lower specificity than unlayered CSS,
|
||||
// so we need to explicitly set the badge styles here.
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2em 0.8em;
|
||||
border-radius: 2em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
|
||||
1019
webapp/components/Chat/Chat.spec.js
Normal file
1019
webapp/components/Chat/Chat.spec.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -2,8 +2,8 @@ import gql from 'graphql-tag'
|
||||
|
||||
export const createMessageMutation = () => {
|
||||
return gql`
|
||||
mutation ($roomId: ID!, $content: String, $files: [FileInput]) {
|
||||
CreateMessage(roomId: $roomId, content: $content, files: $files) {
|
||||
mutation ($roomId: ID, $userId: ID, $content: String, $files: [FileInput]) {
|
||||
CreateMessage(roomId: $roomId, userId: $userId, content: $content, files: $files) {
|
||||
#_id
|
||||
id
|
||||
indexId
|
||||
@ -35,8 +35,14 @@ export const createMessageMutation = () => {
|
||||
|
||||
export const messageQuery = () => {
|
||||
return gql`
|
||||
query ($roomId: ID!, $first: Int, $offset: Int) {
|
||||
Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) {
|
||||
query ($roomId: ID!, $first: Int, $offset: Int, $beforeIndex: Int) {
|
||||
Message(
|
||||
roomId: $roomId
|
||||
first: $first
|
||||
offset: $offset
|
||||
beforeIndex: $beforeIndex
|
||||
orderBy: indexId_desc
|
||||
) {
|
||||
_id
|
||||
id
|
||||
indexId
|
||||
@ -103,6 +109,18 @@ export const chatMessageAdded = () => {
|
||||
`
|
||||
}
|
||||
|
||||
export const chatMessageStatusUpdated = () => {
|
||||
return gql`
|
||||
subscription chatMessageStatusUpdated {
|
||||
chatMessageStatusUpdated {
|
||||
roomId
|
||||
messageIds
|
||||
status
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const markMessagesAsSeen = () => {
|
||||
return gql`
|
||||
mutation ($messageIds: [String!]) {
|
||||
|
||||
@ -1,18 +1,27 @@
|
||||
import gql from 'graphql-tag'
|
||||
import { imageUrls } from './fragments/imageUrls'
|
||||
|
||||
export const createRoom = () => gql`
|
||||
export const createGroupRoom = () => gql`
|
||||
${imageUrls}
|
||||
|
||||
mutation ($userId: ID!) {
|
||||
CreateRoom(userId: $userId) {
|
||||
mutation ($groupId: ID!) {
|
||||
CreateGroupRoom(groupId: $groupId) {
|
||||
id
|
||||
roomId
|
||||
roomName
|
||||
avatar
|
||||
isGroupRoom
|
||||
lastMessageAt
|
||||
createdAt
|
||||
unreadCount
|
||||
#avatar
|
||||
group {
|
||||
id
|
||||
name
|
||||
slug
|
||||
avatar {
|
||||
...imageUrls
|
||||
}
|
||||
}
|
||||
users {
|
||||
_id
|
||||
id
|
||||
@ -28,15 +37,24 @@ export const createRoom = () => gql`
|
||||
export const roomQuery = () => gql`
|
||||
${imageUrls}
|
||||
|
||||
query Room($first: Int, $offset: Int, $id: ID) {
|
||||
Room(first: $first, offset: $offset, id: $id, orderBy: [createdAt_desc, lastMessageAt_desc]) {
|
||||
query Room($first: Int, $before: String, $id: ID, $userId: ID, $groupId: ID) {
|
||||
Room(first: $first, before: $before, id: $id, userId: $userId, groupId: $groupId) {
|
||||
id
|
||||
roomId
|
||||
roomName
|
||||
avatar
|
||||
isGroupRoom
|
||||
lastMessageAt
|
||||
createdAt
|
||||
unreadCount
|
||||
group {
|
||||
id
|
||||
name
|
||||
slug
|
||||
avatar {
|
||||
...imageUrls
|
||||
}
|
||||
}
|
||||
lastMessage {
|
||||
_id
|
||||
id
|
||||
|
||||
@ -137,3 +137,30 @@ export const searchHashtags = gql`
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const searchChatTargets = gql`
|
||||
${imageUrls}
|
||||
|
||||
query ($query: String!, $limit: Int) {
|
||||
searchChatTargets(query: $query, limit: $limit) {
|
||||
__typename
|
||||
... on User {
|
||||
id
|
||||
slug
|
||||
name
|
||||
avatar {
|
||||
...imageUrls
|
||||
}
|
||||
}
|
||||
... on Group {
|
||||
id
|
||||
slug
|
||||
groupName: name
|
||||
avatar {
|
||||
...imageUrls
|
||||
}
|
||||
myRole
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@ -18,7 +18,7 @@ module.exports = {
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
lines: 84,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
coverageProvider: 'v8',
|
||||
|
||||
@ -37,7 +37,7 @@ describe('default.vue', () => {
|
||||
getters: {
|
||||
'auth/isLoggedIn': () => true,
|
||||
'chat/showChat': () => {
|
||||
return { showChat: false, roomID: null }
|
||||
return { showChat: false, chatUserId: null, groupId: null }
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
|
||||
@ -10,9 +10,14 @@
|
||||
</div>
|
||||
<page-footer class="desktop-footer" />
|
||||
<div id="overlay" />
|
||||
<div v-if="getShowChat.showChat" class="chat-modul">
|
||||
<div v-if="getShowChat.showChat && !isMobile" class="chat-modul">
|
||||
<client-only>
|
||||
<chat singleRoom :roomId="getShowChat.roomID" @close-single-room="closeSingleRoom" />
|
||||
<chat
|
||||
singleRoom
|
||||
:userId="getShowChat.chatUserId"
|
||||
:groupId="getShowChat.groupId"
|
||||
@close-single-room="closeSingleRoom"
|
||||
/>
|
||||
</client-only>
|
||||
</div>
|
||||
</div>
|
||||
@ -38,16 +43,33 @@ export default {
|
||||
getShowChat: 'chat/showChat',
|
||||
}),
|
||||
},
|
||||
watch: {
|
||||
'getShowChat.showChat'(open) {
|
||||
if (open && this.isMobile) {
|
||||
const { chatUserId, groupId } = this.getShowChat
|
||||
const query = {}
|
||||
if (chatUserId) query.userId = chatUserId
|
||||
if (groupId) query.groupId = groupId
|
||||
this.showChat({ showChat: false, chatUserId: null, groupId: null })
|
||||
if (this.$route.path === '/chat') {
|
||||
// Already on chat page — update query to trigger openFromQuery watcher
|
||||
this.$router.replace({ path: '/chat', query })
|
||||
} else {
|
||||
this.$router.push({ path: '/chat', query })
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
showChat: 'chat/SET_OPEN_CHAT',
|
||||
}),
|
||||
closeSingleRoom() {
|
||||
this.showChat({ showChat: false, roomID: null })
|
||||
this.showChat({ showChat: false, chatUserId: null, groupId: null })
|
||||
},
|
||||
},
|
||||
beforeCreate() {
|
||||
this.$store.commit('chat/SET_OPEN_CHAT', { showChat: false, roomID: null })
|
||||
this.$store.commit('chat/SET_OPEN_CHAT', { showChat: false, chatUserId: null, groupId: null })
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="chat-page">
|
||||
<add-chat-room-by-user-search
|
||||
v-if="showUserSearch"
|
||||
v-if="showSearch"
|
||||
ref="searchPanel"
|
||||
@add-chat-room="addChatRoom"
|
||||
@close-user-search="showUserSearch = false"
|
||||
@add-group-chat-room="addGroupChatRoom"
|
||||
@close-user-search="showSearch = false"
|
||||
/>
|
||||
<client-only>
|
||||
<chat
|
||||
:roomId="getShowChat.showChat ? getShowChat.roomID : null"
|
||||
:userId="getShowChat.showChat ? getShowChat.chatUserId : null"
|
||||
ref="chat"
|
||||
@toggle-user-search="showUserSearch = !showUserSearch"
|
||||
@toggle-user-search="toggleSearch"
|
||||
:show-room="showRoom"
|
||||
/>
|
||||
</client-only>
|
||||
@ -28,11 +30,15 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showUserSearch: false,
|
||||
showSearch: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.showChat({ showChat: false, roomID: null })
|
||||
this.showChat({ showChat: false, chatUserId: null, groupId: null })
|
||||
this.openFromQuery()
|
||||
},
|
||||
watch: {
|
||||
'$route.query': 'openFromQuery',
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
@ -43,12 +49,74 @@ export default {
|
||||
...mapMutations({
|
||||
showChat: 'chat/SET_OPEN_CHAT',
|
||||
}),
|
||||
addChatRoom(userID) {
|
||||
this.$refs.chat.newRoom(userID)
|
||||
toggleSearch() {
|
||||
this.showSearch = !this.showSearch
|
||||
if (this.showSearch) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.searchPanel?.$el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
}
|
||||
},
|
||||
openFromQuery() {
|
||||
const { userId, groupId } = this.$route.query
|
||||
if (!userId && !groupId) return
|
||||
// Wait for client-only chat component to be available
|
||||
const tryOpen = () => {
|
||||
if (this.$refs.chat) {
|
||||
if (groupId) {
|
||||
this.$refs.chat.newGroupRoom(groupId)
|
||||
} else if (userId) {
|
||||
this.$refs.chat.newRoom(userId)
|
||||
}
|
||||
// Clean query params from URL
|
||||
this.$router.replace({ path: '/chat', query: {} })
|
||||
} else {
|
||||
setTimeout(tryOpen, 100)
|
||||
}
|
||||
}
|
||||
tryOpen()
|
||||
},
|
||||
addChatRoom(user) {
|
||||
this.$refs.chat.newRoom(user)
|
||||
},
|
||||
addGroupChatRoom(groupId) {
|
||||
this.$refs.chat.newGroupRoom(groupId)
|
||||
},
|
||||
showRoom(roomId) {
|
||||
this.showChat({ showChat: true, roomID: roomId })
|
||||
this.showChat({ showChat: true, chatUserId: roomId })
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@media (max-width: 768px) {
|
||||
.layout-default:has(.chat-page) {
|
||||
> .ds-container {
|
||||
max-width: 100% !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
.main-container {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
.chat-page {
|
||||
padding-top: var(--header-height, 66px);
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -377,6 +377,43 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
|
||||
data-appearance="outline"
|
||||
data-original-title="null"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
|
||||
/>
|
||||
<path
|
||||
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
chat.groupChatButton.label
|
||||
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@ -1274,6 +1311,8 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@ -1783,6 +1822,8 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@ -2432,6 +2473,43 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
|
||||
data-appearance="outline"
|
||||
data-original-title="null"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
|
||||
/>
|
||||
<path
|
||||
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
chat.groupChatButton.label
|
||||
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@ -3468,6 +3546,43 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
|
||||
data-appearance="outline"
|
||||
data-original-title="null"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
|
||||
/>
|
||||
<path
|
||||
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
chat.groupChatButton.label
|
||||
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@ -4324,6 +4439,8 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@ -5127,6 +5244,8 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@ -6043,6 +6162,43 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
|
||||
data-appearance="outline"
|
||||
data-original-title="null"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
|
||||
/>
|
||||
<path
|
||||
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
chat.groupChatButton.label
|
||||
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@ -7106,6 +7262,43 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a hidde
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
|
||||
data-appearance="outline"
|
||||
data-original-title="null"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
|
||||
/>
|
||||
<path
|
||||
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
chat.groupChatButton.label
|
||||
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@ -8152,6 +8345,43 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a hidde
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="os-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] disabled:hover:bg-transparent disabled:hover:text-[var(--color-disabled)] disabled:hover:border-[var(--color-disabled)] disabled:active:bg-transparent disabled:active:text-[var(--color-disabled)] disabled:active:border-[var(--color-disabled)] h-[36px] min-w-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle w-full border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] has-tooltip"
|
||||
data-appearance="outline"
|
||||
data-original-title="null"
|
||||
data-variant="primary"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="os-button__icon inline-flex items-center shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current -ml-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="os-icon inline-flex items-center align-bottom shrink-0 h-[1.2em] [&>svg]:h-full [&>svg]:w-auto [&>svg]:fill-current"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M27.23,24.91A9,9,0,0,0,22.44,9.52,8.57,8.57,0,0,0,21,9.41a8.94,8.94,0,0,0-8.92,10.14c0,.1,0,.2,0,.29a9,9,0,0,0,.22,1l0,.11a8.93,8.93,0,0,0,.38,1l.13.28a9,9,0,0,0,.45.83l.07.14a9.13,9.13,0,0,1-2.94-.36,1,1,0,0,0-.68,0L7,24.13l.54-1.9a1,1,0,0,0-.32-1,9,9,0,0,1,11.27-14,1,1,0,0,0,1.23-1.58A10.89,10.89,0,0,0,13,3.25a11,11,0,0,0-7.5,19l-1,3.34A1,1,0,0,0,5.9,26.82l4.35-1.93a11,11,0,0,0,4.68.16A9,9,0,0,0,21,27.41a8.81,8.81,0,0,0,2.18-.27l3.41,1.52A1,1,0,0,0,28,27.48Zm-1.77-1.1a1,1,0,0,0-.32,1L25.45,26l-1.79-.8a1,1,0,0,0-.41-.09,1,1,0,0,0-.29,0,6.64,6.64,0,0,1-2,.29,7,7,0,0,1,0-14,6.65,6.65,0,0,1,1.11.09,7,7,0,0,1,3.35,12.31Z"
|
||||
/>
|
||||
<path
|
||||
d="M17.82 17.08H17a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM21.41 17.08h-.82a1 1 0 0 0 0 2h.82a1 1 0 0 0 0-2zM25 17.08h-.82a1 1 0 0 0 0 2H25a1 1 0 0 0 0-2z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
chat.groupChatButton.label
|
||||
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
@ -80,6 +80,21 @@
|
||||
:loading="$apollo.loading"
|
||||
@update="updateJoinLeave"
|
||||
/>
|
||||
<!-- Group chat -->
|
||||
<os-button
|
||||
v-if="isGroupMemberNonePending"
|
||||
variant="primary"
|
||||
appearance="outline"
|
||||
full-width
|
||||
v-tooltip="{
|
||||
content: $t('chat.groupChatButton.tooltip', { name: groupName }),
|
||||
placement: 'bottom-start',
|
||||
}"
|
||||
@click="showOrChangeGroupChat(group.id)"
|
||||
>
|
||||
<template #icon><os-icon :icon="icons.chatBubble" /></template>
|
||||
{{ $t('chat.groupChatButton.label') }}
|
||||
</os-button>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="ds-mt-small ds-mb-small">
|
||||
@ -308,7 +323,7 @@ import PostTeaser from '~/components/PostTeaser/PostTeaser.vue'
|
||||
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
|
||||
import ProfileList from '~/components/features/ProfileList/ProfileList'
|
||||
import SortCategories from '~/mixins/sortCategoriesMixin.js'
|
||||
import { mapGetters } from 'vuex'
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
import GetCategories from '~/mixins/getCategoriesMixin.js'
|
||||
// import SocialMedia from '~/components/SocialMedia/SocialMedia'
|
||||
// import TabNavigation from '~/components/_new/generic/TabNavigation/TabNavigation'
|
||||
@ -374,6 +389,7 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentUser: 'auth/user',
|
||||
getShowChat: 'chat/showChat',
|
||||
}),
|
||||
groupName() {
|
||||
const { name } = this.group || {}
|
||||
@ -440,6 +456,15 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
showChat: 'chat/SET_OPEN_CHAT',
|
||||
}),
|
||||
showOrChangeGroupChat(groupId) {
|
||||
if (this.getShowChat.showChat) {
|
||||
this.showChat({ showChat: false, chatUserId: null, groupId: null })
|
||||
}
|
||||
this.showChat({ showChat: true, chatUserId: null, groupId })
|
||||
},
|
||||
// handleTab(tab) {
|
||||
// if (this.tabActive !== tab) {
|
||||
// this.tabActive = tab
|
||||
|
||||
@ -577,11 +577,11 @@ export default {
|
||||
if (type === 'following') this.followingCount = count
|
||||
if (type === 'followedBy') this.followedByCount = count
|
||||
},
|
||||
async showOrChangeChat(roomID) {
|
||||
showOrChangeChat(userId) {
|
||||
if (this.getShowChat.showChat) {
|
||||
await this.showChat({ showChat: false, roomID: null })
|
||||
this.showChat({ showChat: false, chatUserId: null })
|
||||
}
|
||||
await this.showChat({ showChat: true, roomID })
|
||||
this.showChat({ showChat: true, chatUserId: userId })
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
export const state = () => {
|
||||
return {
|
||||
showChat: false,
|
||||
roomID: null,
|
||||
chatUserId: null,
|
||||
groupId: null,
|
||||
unreadRoomCount: 0,
|
||||
}
|
||||
}
|
||||
@ -9,23 +10,18 @@ export const state = () => {
|
||||
export const mutations = {
|
||||
SET_OPEN_CHAT(state, ctx) {
|
||||
state.showChat = ctx.showChat || false
|
||||
state.roomID = ctx.roomID || null
|
||||
state.chatUserId = ctx.chatUserId || null
|
||||
state.groupId = ctx.groupId || null
|
||||
},
|
||||
UPDATE_ROOM_COUNT(state, count) {
|
||||
state.unreadRoomCount = count
|
||||
},
|
||||
UPDATE_ROOM_ID(state, roomid) {
|
||||
state.roomId = roomid || null
|
||||
},
|
||||
}
|
||||
|
||||
export const getters = {
|
||||
showChat(state) {
|
||||
return state
|
||||
},
|
||||
roomID(state) {
|
||||
return state
|
||||
},
|
||||
unreadRoomCount(state) {
|
||||
return state.unreadRoomCount
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user