mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2026-04-06 01:25:38 +00:00
fix(webapp): enhance group chat UX with avatars and file handling (#9485)
This commit is contained in:
parent
5b5db947e7
commit
700aaaf0b2
@ -1,7 +1,9 @@
|
||||
export default {
|
||||
url: { primary: true, type: 'string', uri: { allowRelative: true } },
|
||||
name: { type: 'string' },
|
||||
extension: { type: 'string', allow: [null, ''] },
|
||||
type: { type: 'string' },
|
||||
duration: { type: 'number', allow: [null] },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
updatedAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
}
|
||||
|
||||
@ -28,13 +28,17 @@ export interface AddAttachmentOpts {
|
||||
export interface FileInput {
|
||||
upload?: Promise<FileUpload>
|
||||
name: string
|
||||
extension?: string | null
|
||||
type: string
|
||||
duration?: number | null
|
||||
}
|
||||
|
||||
export interface File {
|
||||
url: string
|
||||
name: string
|
||||
extension?: string | null
|
||||
type: string
|
||||
duration?: number | null
|
||||
}
|
||||
|
||||
export interface Attachments {
|
||||
@ -143,8 +147,15 @@ export const attachments = (config: S3Config) => {
|
||||
uniqueFilename,
|
||||
})
|
||||
|
||||
const { name, type } = fileInput
|
||||
const file = { url, name, type, ...fileAttributes }
|
||||
const { name, extension, type, duration } = fileInput
|
||||
const file = {
|
||||
url,
|
||||
name,
|
||||
...(extension && { extension }),
|
||||
type,
|
||||
...(duration != null && { duration }),
|
||||
...fileAttributes,
|
||||
}
|
||||
// const mimeType = uploadFile.mimetype.split('/')[0]
|
||||
// const nodeType = `Mime${mimeType.replace(/^./, mimeType[0].toUpperCase())}`
|
||||
// CREATE (file:${['File', nodeType].filter(Boolean).join(':')})
|
||||
|
||||
@ -17,7 +17,7 @@ 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 resolvers, { chatMessageAddedFilter, chatMessageStatusUpdatedFilter } from './messages'
|
||||
|
||||
import type { ApolloTestSetup } from '@root/test/helpers'
|
||||
import type { Context } from '@src/context'
|
||||
@ -852,4 +852,34 @@ describe('Message', () => {
|
||||
expect(result.errors?.[0].message).toContain('Message must have content or files')
|
||||
})
|
||||
})
|
||||
|
||||
describe('File field resolvers', () => {
|
||||
it('returns extension when present', () => {
|
||||
expect(resolvers.File.extension({ extension: 'jpg' })).toBe('jpg')
|
||||
})
|
||||
|
||||
it('returns null when extension is undefined', () => {
|
||||
expect(resolvers.File.extension({})).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when extension is null', () => {
|
||||
expect(resolvers.File.extension({ extension: null })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns duration when present', () => {
|
||||
expect(resolvers.File.duration({ duration: 5.3 })).toBe(5.3)
|
||||
})
|
||||
|
||||
it('returns null when duration is undefined', () => {
|
||||
expect(resolvers.File.duration({})).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when duration is null', () => {
|
||||
expect(resolvers.File.duration({ duration: null })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns duration 0 as valid', () => {
|
||||
expect(resolvers.File.duration({ duration: 0 })).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -280,4 +280,8 @@ export default {
|
||||
},
|
||||
}),
|
||||
},
|
||||
File: {
|
||||
extension: (parent: { extension?: string | null }) => parent.extension ?? null,
|
||||
duration: (parent: { duration?: number | null }) => parent.duration ?? null,
|
||||
},
|
||||
}
|
||||
|
||||
@ -88,11 +88,20 @@ export default {
|
||||
try {
|
||||
const first = params.first || 10
|
||||
const before = params.before || null
|
||||
const search = params.search || null
|
||||
const result = await session.readTransaction(async (transaction) => {
|
||||
const conditions: string[] = []
|
||||
if (before) conditions.push('sortDate < $before')
|
||||
if (search) conditions.push('toLower(roomName) CONTAINS toLower($search)')
|
||||
const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const cypher = `
|
||||
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room)
|
||||
WITH room, COALESCE(room.lastMessageAt, room.createdAt) AS sortDate
|
||||
${before ? 'WHERE sortDate < $before' : ''}
|
||||
OPTIONAL MATCH (room)-[:ROOM_FOR]->(g:Group)
|
||||
OPTIONAL MATCH (room)<-[:CHATS_IN]-(otherUser:User)
|
||||
WHERE g IS NULL AND otherUser.id <> $currentUserId
|
||||
WITH room, COALESCE(room.lastMessageAt, room.createdAt) AS sortDate,
|
||||
COALESCE(g.name, otherUser.name) AS roomName
|
||||
${whereClause}
|
||||
RETURN room.id AS id
|
||||
ORDER BY sortDate DESC
|
||||
LIMIT toInteger($first)
|
||||
@ -101,6 +110,7 @@ export default {
|
||||
currentUserId: context.user.id,
|
||||
first,
|
||||
before,
|
||||
search,
|
||||
})
|
||||
})
|
||||
const roomIds: string[] = result.records.map((record) => record.get('id') as string)
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
type File {
|
||||
url: ID!
|
||||
name: String
|
||||
extension: String
|
||||
type: String
|
||||
duration: Float
|
||||
# size: Int
|
||||
# audio: Boolean
|
||||
# duration: Float
|
||||
# preview: String
|
||||
# progress: Int
|
||||
}
|
||||
@ -12,5 +13,7 @@ type File {
|
||||
input FileInput {
|
||||
upload: Upload
|
||||
name: String
|
||||
extension: String
|
||||
type: String
|
||||
duration: Float
|
||||
}
|
||||
|
||||
@ -70,7 +70,7 @@ type Mutation {
|
||||
}
|
||||
|
||||
type Query {
|
||||
Room(id: ID, userId: ID, groupId: ID, first: Int, before: String, orderBy: [_RoomOrdering]): [Room]
|
||||
Room(id: ID, userId: ID, groupId: ID, first: Int, before: String, search: String, orderBy: [_RoomOrdering]): [Room]
|
||||
UnreadRooms: Int
|
||||
}
|
||||
|
||||
|
||||
26
cypress/e2e/chat/Avatar.feature
Normal file
26
cypress/e2e/chat/Avatar.feature
Normal file
@ -0,0 +1,26 @@
|
||||
Feature: Chat Avatars
|
||||
As a user
|
||||
I want to see avatars for all chat participants
|
||||
So that I can visually identify who I am chatting with
|
||||
|
||||
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: Room list shows avatar for each room
|
||||
Given "alice" sends a chat message "Hello" to "bob"
|
||||
And I am logged in as "bob"
|
||||
And I navigate to page "/chat"
|
||||
Then I see an avatar in the room list
|
||||
|
||||
Scenario: Messages show avatar for other user and own messages
|
||||
Given "alice" sends a chat message "Hello" 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" in the chat
|
||||
And I see an avatar for the other user's message
|
||||
When I send the message "Hi Alice!" in the chat
|
||||
Then I see an avatar for my own message
|
||||
29
cypress/e2e/chat/ExternalRoom.feature
Normal file
29
cypress/e2e/chat/ExternalRoom.feature
Normal file
@ -0,0 +1,29 @@
|
||||
Feature: External Room Creation
|
||||
As a user
|
||||
I want incoming messages from new users to appear in my room list
|
||||
Without losing focus on my current conversation
|
||||
|
||||
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: New room from external message does not steal focus
|
||||
Given "alice" sends a chat message "Hi Bob!" to "bob"
|
||||
And I am logged in as "bob"
|
||||
And I navigate to page "/chat"
|
||||
And I click on the room "Alice"
|
||||
And I see the message "Hi Bob!" in the chat
|
||||
When "charlie" sends a chat message "Surprise!" to "bob"
|
||||
Then I see "Charlie" in the room list
|
||||
And I see the message "Hi Bob!" in the chat
|
||||
|
||||
Scenario: New room from external message shows unread badge
|
||||
Given "alice" sends a chat message "Hi Bob!" to "bob"
|
||||
And I am logged in as "bob"
|
||||
And I navigate to page "/chat"
|
||||
And I click on the room "Alice"
|
||||
When "charlie" sends a chat message "Surprise!" to "bob"
|
||||
Then I see 1 unread chat message in the header
|
||||
17
cypress/e2e/chat/FileMessage.feature
Normal file
17
cypress/e2e/chat/FileMessage.feature
Normal file
@ -0,0 +1,17 @@
|
||||
Feature: File Messages
|
||||
As a user
|
||||
I want to send files and audio messages in chat
|
||||
So that I can share media with other users
|
||||
|
||||
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: File message shows filename in room preview
|
||||
Given I am logged in as "alice"
|
||||
And I navigate to page "/chat?userId=bob"
|
||||
When I upload the file "humanconnection.png" in the chat
|
||||
Then I see the room preview contains "humanconnection"
|
||||
|
||||
@ -35,3 +35,11 @@ Feature: Group Chat
|
||||
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
|
||||
|
||||
Scenario: Group room shows group icon before name in room list
|
||||
Given "bob" is a member of group "test-group"
|
||||
And "alice" opens the group chat for "test-group"
|
||||
And "alice" sends a group chat message "Hello group" to "test-group"
|
||||
And I am logged in as "bob"
|
||||
And I navigate to page "/chat"
|
||||
Then I see a group icon before "Test Group" in the room list
|
||||
|
||||
31
cypress/e2e/chat/RoomFilter.feature
Normal file
31
cypress/e2e/chat/RoomFilter.feature
Normal file
@ -0,0 +1,31 @@
|
||||
Feature: Chat Room Filter
|
||||
As a user
|
||||
I want to filter chat rooms by name
|
||||
So that I can quickly find a conversation
|
||||
|
||||
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 |
|
||||
And "alice" sends a chat message "Hi Bob" to "bob"
|
||||
And "charlie" sends a chat message "Hi Bob" to "bob"
|
||||
|
||||
Scenario: Filter rooms by name
|
||||
Given I am logged in as "bob"
|
||||
And I navigate to page "/chat"
|
||||
Then I see "Charlie" in the room list
|
||||
And I see "Alice" in the room list
|
||||
When I type "Ali" in the room filter
|
||||
Then I see "Alice" in the room list
|
||||
And I do not see "Charlie" in the room list
|
||||
|
||||
Scenario: Clear filter restores all rooms
|
||||
Given I am logged in as "bob"
|
||||
And I navigate to page "/chat"
|
||||
When I type "Ali" in the room filter
|
||||
Then I see "Alice" in the room list
|
||||
When I clear the room filter
|
||||
Then I see "Alice" in the room list
|
||||
And I see "Charlie" in the room list
|
||||
@ -0,0 +1,13 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep("I see an avatar for the other user's message", () => {
|
||||
// Message avatars are slotted (light DOM), so query them directly on the host element
|
||||
cy.get('vue-advanced-chat .profile-avatar.vac-message-avatar', { timeout: 15000 })
|
||||
.should('have.length.gte', 1)
|
||||
})
|
||||
|
||||
defineStep('I see an avatar for my own message', () => {
|
||||
// After sending a message, there should be at least 2 message avatars (other + own)
|
||||
cy.get('vue-advanced-chat .profile-avatar.vac-message-avatar', { timeout: 15000 })
|
||||
.should('have.length.gte', 2)
|
||||
})
|
||||
@ -0,0 +1,8 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I see an avatar in the room list', () => {
|
||||
// Room list avatar slots are in the light DOM of vue-advanced-chat
|
||||
cy.get('vue-advanced-chat .profile-avatar', { timeout: 15000 })
|
||||
.first()
|
||||
.should('be.visible')
|
||||
})
|
||||
@ -0,0 +1,18 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I see the room preview contains {string}', (text) => {
|
||||
cy.get('vue-advanced-chat', { timeout: 15000 })
|
||||
.shadow()
|
||||
.find('.vac-room-item', { timeout: 10000 })
|
||||
.first()
|
||||
.should('contain', text)
|
||||
})
|
||||
|
||||
defineStep('I see the room preview contains a microphone icon', () => {
|
||||
cy.get('vue-advanced-chat', { timeout: 15000 })
|
||||
.shadow()
|
||||
.find('.vac-room-item', { timeout: 10000 })
|
||||
.first()
|
||||
.find('.vac-icon-microphone, #vac-icon-microphone')
|
||||
.should('exist')
|
||||
})
|
||||
@ -0,0 +1,20 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I upload the file {string} in the chat', (filename) => {
|
||||
cy.get('vue-advanced-chat', { timeout: 15000 })
|
||||
.shadow()
|
||||
.find('.vac-svg-button', { timeout: 10000 })
|
||||
.filter(':has(#vac-icon-paperclip)')
|
||||
.click()
|
||||
|
||||
cy.get('vue-advanced-chat')
|
||||
.shadow()
|
||||
.find('input[type="file"]', { timeout: 5000 })
|
||||
.selectFile(`cypress/fixtures/${filename}`, { force: true })
|
||||
|
||||
// Wait for file preview and click send
|
||||
cy.get('vue-advanced-chat')
|
||||
.shadow()
|
||||
.find('.vac-icon-send, .vac-svg-button:has(#vac-icon-send)', { timeout: 10000 })
|
||||
.click()
|
||||
})
|
||||
@ -0,0 +1,10 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I see a group icon before {string} in the room list', (name) => {
|
||||
// Group room info slots are in the light DOM
|
||||
cy.get('vue-advanced-chat .room-name-with-icon', { timeout: 15000 })
|
||||
.contains(name)
|
||||
.parent()
|
||||
.find('.room-group-icon')
|
||||
.should('exist')
|
||||
})
|
||||
@ -0,0 +1,9 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I clear the room filter', () => {
|
||||
cy.get('vue-advanced-chat', { timeout: 15000 })
|
||||
.shadow()
|
||||
.find('input[type="search"]', { timeout: 10000 })
|
||||
.focus()
|
||||
.type('{selectall}{del}', { force: true })
|
||||
})
|
||||
@ -0,0 +1,9 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I type {string} in the room filter', (text) => {
|
||||
cy.get('vue-advanced-chat', { timeout: 15000 })
|
||||
.shadow()
|
||||
.find('input[type="search"]', { timeout: 10000 })
|
||||
.should('be.visible')
|
||||
.type(text)
|
||||
})
|
||||
@ -3,6 +3,6 @@ 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')
|
||||
.find('.vac-message-wrapper', { timeout: 10000 })
|
||||
.should('contain', message)
|
||||
})
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
|
||||
|
||||
defineStep('I see {string} in the room list', (name) => {
|
||||
cy.get('vue-advanced-chat', { timeout: 15000 })
|
||||
.shadow()
|
||||
.find('.vac-room-item', { timeout: 10000 })
|
||||
.contains(name)
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
defineStep('I do not see {string} in the room list', (name) => {
|
||||
cy.get('vue-advanced-chat', { timeout: 15000 })
|
||||
.shadow()
|
||||
.find('.vac-room-item', { timeout: 10000 })
|
||||
.contains(name)
|
||||
.should('not.exist')
|
||||
})
|
||||
@ -426,7 +426,7 @@ $chat-message-timestamp: $text-color-soft;
|
||||
$chat-message-checkmark-seen: $text-color-secondary;
|
||||
$chat-message-checkmark: $text-color-soft;
|
||||
$chat-room-color-counter-badge: $text-color-inverse;
|
||||
$chat-room-background-counter-badge: $color-secondary;
|
||||
$chat-room-background-counter-badge: $color-danger;
|
||||
$chat-icon-add: $color-primary;
|
||||
$chat-icon-send: $color-primary;
|
||||
$chat-icon-emoji: $color-primary;
|
||||
|
||||
@ -201,6 +201,52 @@ describe('AddChatRoomByUserSearch.vue', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('apollo searchChatTargets config', () => {
|
||||
it('query returns the searchChatTargets query', () => {
|
||||
wrapper = Wrapper()
|
||||
const apolloConfig = wrapper.vm.$options.apollo.searchChatTargets
|
||||
const query = apolloConfig.query.call(wrapper.vm)
|
||||
const operation = query.definitions.find((d) => d.kind === 'OperationDefinition')
|
||||
expect(operation.name.value).toBe('searchChatTargets')
|
||||
})
|
||||
|
||||
it('variables returns query and limit', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.query = 'test'
|
||||
const apolloConfig = wrapper.vm.$options.apollo.searchChatTargets
|
||||
expect(apolloConfig.variables.call(wrapper.vm)).toEqual({ query: 'test', limit: 10 })
|
||||
})
|
||||
|
||||
it('skip returns true when search is not started', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.query = 'ab'
|
||||
const apolloConfig = wrapper.vm.$options.apollo.searchChatTargets
|
||||
expect(apolloConfig.skip.call(wrapper.vm)).toBe(true)
|
||||
})
|
||||
|
||||
it('skip returns false when search is started', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.query = 'abc'
|
||||
const apolloConfig = wrapper.vm.$options.apollo.searchChatTargets
|
||||
expect(apolloConfig.skip.call(wrapper.vm)).toBe(false)
|
||||
})
|
||||
|
||||
it('update normalizes results with groupName fallback', () => {
|
||||
wrapper = Wrapper()
|
||||
const apolloConfig = wrapper.vm.$options.apollo.searchChatTargets
|
||||
apolloConfig.update.call(wrapper.vm, {
|
||||
searchChatTargets: [
|
||||
{ id: '1', name: 'User', __typename: 'User' },
|
||||
{ id: '2', groupName: 'Group', __typename: 'Group' },
|
||||
],
|
||||
})
|
||||
expect(wrapper.vm.results).toEqual([
|
||||
{ id: '1', name: 'User', __typename: 'User' },
|
||||
{ id: '2', name: 'Group', groupName: 'Group', __typename: 'Group' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('beforeDestroy', () => {
|
||||
it('clears blur timeout', () => {
|
||||
jest.useFakeTimers()
|
||||
|
||||
@ -42,7 +42,14 @@
|
||||
<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-name">
|
||||
<os-icon
|
||||
v-if="option.__typename === 'Group'"
|
||||
:icon="icons.group"
|
||||
class="chat-search-group-icon"
|
||||
/>
|
||||
{{ option.name }}
|
||||
</span>
|
||||
<span class="chat-search-result-detail">
|
||||
{{ option.__typename === 'Group' ? `&${option.slug}` : `@${option.slug}` }}
|
||||
</span>
|
||||
@ -203,6 +210,10 @@ export default {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chat-search-group-icon {
|
||||
vertical-align: middle !important;
|
||||
margin-right: 0;
|
||||
}
|
||||
.chat-search-result-detail {
|
||||
font-size: $font-size-small;
|
||||
color: $text-color-soft;
|
||||
|
||||
@ -185,17 +185,18 @@ describe('Chat.vue', () => {
|
||||
expect(prepared.date).toBeDefined()
|
||||
})
|
||||
|
||||
it('normalizes avatar', () => {
|
||||
it('extracts w320 from avatar object', () => {
|
||||
const msg = mockMessage({ avatar: { w320: 'http://img.jpg' } })
|
||||
const prepared = wrapper.vm.prepareMessage(msg)
|
||||
expect(prepared.avatar).toBe('http://img.jpg')
|
||||
expect(prepared._originalAvatar).toBe('http://img.jpg')
|
||||
})
|
||||
|
||||
it('handles null avatar', () => {
|
||||
const msg = mockMessage({ avatar: null })
|
||||
it('generates initials avatar when avatar is null', () => {
|
||||
const msg = mockMessage({ avatar: null, username: 'Otto Normal' })
|
||||
const prepared = wrapper.vm.prepareMessage(msg)
|
||||
expect(prepared.avatar).toBeNull()
|
||||
expect(prepared.avatar).toContain('data:image/svg+xml')
|
||||
expect(prepared.avatar).toContain('ON')
|
||||
})
|
||||
})
|
||||
|
||||
@ -589,7 +590,7 @@ describe('Chat.vue', () => {
|
||||
const messageCall = mocks.$apollo.query.mock.calls.find(
|
||||
([arg]) => arg.variables?.roomId === 'r-default',
|
||||
)
|
||||
expect(messageCall[0].variables.first).toBe(wrapper.vm.messagePageSize)
|
||||
expect(messageCall[0].variables.first).toBe(20)
|
||||
})
|
||||
|
||||
it('uses beforeIndex cursor for subsequent loads', async () => {
|
||||
@ -712,7 +713,7 @@ describe('Chat.vue', () => {
|
||||
expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'test', type: 'text/plain' }),
|
||||
expect.objectContaining({ name: 'test', extension: 'txt', type: 'text/plain' }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
@ -1016,4 +1017,283 @@ describe('Chat.vue', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentLocaleIso watcher', () => {
|
||||
it('reformats message dates when locale changes', async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.messages = [mockMessage({ _rawDate: '2026-06-15T14:30:00Z' })]
|
||||
mocks.$i18n.locale = () => 'de'
|
||||
wrapper.vm.$options.watch.currentLocaleIso.call(wrapper.vm)
|
||||
expect(wrapper.vm.messages[0].timestamp).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandChatLink', () => {
|
||||
it('returns chat route with no query when no room selected', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.vm.expandChatLink).toEqual({ name: 'chat' })
|
||||
})
|
||||
|
||||
it('returns groupId query for group rooms', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.selectedRoom = mockRoom({
|
||||
isGroupRoom: true,
|
||||
groupProfile: { id: 'g1' },
|
||||
users: [{ id: 'current-user' }],
|
||||
})
|
||||
expect(wrapper.vm.expandChatLink).toEqual({ name: 'chat', query: { groupId: 'g1' } })
|
||||
})
|
||||
|
||||
it('returns userId query for DM rooms', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.selectedRoom = mockRoom()
|
||||
expect(wrapper.vm.expandChatLink).toEqual({ name: 'chat', query: { userId: 'other-user' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('roomHeaderLink', () => {
|
||||
it('returns null when no other user found in DM', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.selectedRoom = mockRoom({ users: [{ id: 'current-user', name: 'Me' }] })
|
||||
expect(wrapper.vm.roomHeaderLink).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildLastMessage', () => {
|
||||
it('returns file name for non-audio files', () => {
|
||||
wrapper = Wrapper()
|
||||
const result = wrapper.vm.buildLastMessage({
|
||||
content: '',
|
||||
files: [{ name: 'photo', type: 'image/jpeg' }],
|
||||
})
|
||||
expect(result.content).toBe('\uD83D\uDCCE photo')
|
||||
})
|
||||
|
||||
it('returns empty for audio files', () => {
|
||||
wrapper = Wrapper()
|
||||
const result = wrapper.vm.buildLastMessage({
|
||||
content: '',
|
||||
files: [{ name: 'audio', type: 'audio/mpeg' }],
|
||||
})
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchRooms', () => {
|
||||
it('fetches rooms with search term', async () => {
|
||||
wrapper = Wrapper()
|
||||
mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } })
|
||||
await wrapper.vm.searchRooms({ value: 'test' })
|
||||
expect(wrapper.vm.roomSearch).toBe('test')
|
||||
})
|
||||
|
||||
it('clears search and resets cursor', async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.roomSearch = 'old'
|
||||
mocks.$apollo.query.mockResolvedValue({ data: { Room: [mockRoom()] } })
|
||||
await wrapper.vm.searchRooms({ value: '' })
|
||||
expect(wrapper.vm.roomSearch).toBe('')
|
||||
expect(wrapper.vm.roomCursor).toBeDefined()
|
||||
})
|
||||
|
||||
it('increments generation to handle concurrent searches', async () => {
|
||||
wrapper = Wrapper()
|
||||
mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } })
|
||||
await wrapper.vm.searchRooms({ value: 'a' })
|
||||
expect(wrapper.vm.roomSearchGeneration).toBe(1)
|
||||
await wrapper.vm.searchRooms({ value: 'ab' })
|
||||
expect(wrapper.vm.roomSearchGeneration).toBe(2)
|
||||
})
|
||||
|
||||
it('sets roomObserverDirty when roomsLoaded was true', async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.roomsLoaded = true
|
||||
mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } })
|
||||
await wrapper.vm.searchRooms({ value: 'x' })
|
||||
// roomObserverDirty was set, roomsLoaded is true (< pageSize), so no reinit
|
||||
expect(wrapper.vm.roomObserverDirty).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMoreRooms', () => {
|
||||
it('delegates to fetchRooms with current search', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.fetchRooms = jest.fn()
|
||||
wrapper.vm.roomSearch = 'test'
|
||||
wrapper.vm.fetchMoreRooms()
|
||||
expect(wrapper.vm.fetchRooms).toHaveBeenCalledWith({ search: 'test' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchRooms replace mode', () => {
|
||||
it('replaces rooms instead of appending', async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.rooms = [mockRoom({ id: 'old', roomId: 'old' })]
|
||||
mocks.$apollo.query.mockResolvedValue({
|
||||
data: { Room: [mockRoom({ id: 'new', roomId: 'new' })] },
|
||||
})
|
||||
await wrapper.vm.fetchRooms({ replace: true })
|
||||
expect(wrapper.vm.rooms).toHaveLength(1)
|
||||
expect(wrapper.vm.rooms[0].id).toBe('new')
|
||||
})
|
||||
|
||||
it('selects first room in singleRoom mode', async () => {
|
||||
wrapper = Wrapper({ singleRoom: true })
|
||||
mocks.$apollo.query.mockResolvedValue({ data: { Room: [mockRoom()] } })
|
||||
await wrapper.vm.fetchRooms({})
|
||||
expect(wrapper.vm.activeRoomId).toBe('room-1')
|
||||
})
|
||||
|
||||
it('fetches by room id', async () => {
|
||||
wrapper = Wrapper()
|
||||
mocks.$apollo.query.mockResolvedValue({ data: { Room: [mockRoom()] } })
|
||||
await wrapper.vm.fetchRooms({ room: { id: 'room-1' } })
|
||||
expect(mocks.$apollo.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variables: expect.objectContaining({ id: 'room-1' }) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMessages edge cases', () => {
|
||||
it('skips when room has no roomId', async () => {
|
||||
wrapper = Wrapper()
|
||||
await wrapper.vm.fetchMessages({ room: {} })
|
||||
expect(wrapper.vm.activeRoomId).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks external room auto-select', async () => {
|
||||
wrapper = Wrapper()
|
||||
const currentRoom = mockRoom({ id: 'current', roomId: 'current' })
|
||||
wrapper.vm.selectedRoom = currentRoom
|
||||
wrapper.vm.activeRoomId = 'current'
|
||||
wrapper.vm.messages = [{ id: 'msg1', content: 'existing' }]
|
||||
wrapper.vm._externalRoomIds = new Set(['ext-room'])
|
||||
await wrapper.vm.fetchMessages({ room: { id: 'ext', roomId: 'ext-room' } })
|
||||
expect(wrapper.vm.activeRoomId).toBe('current')
|
||||
expect(wrapper.vm.selectedRoom).toBe(currentRoom)
|
||||
expect(wrapper.vm.messages).toEqual([{ id: 'msg1', content: 'existing' }])
|
||||
})
|
||||
|
||||
it('handles virtual rooms with early return', async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.selectedRoom = null
|
||||
await wrapper.vm.fetchMessages({ room: { id: 'temp-123', roomId: 'temp-123' } })
|
||||
// Virtual rooms toggle messagesLoaded and return early
|
||||
expect(wrapper.vm.selectedRoom).toEqual({ id: 'temp-123', roomId: 'temp-123' })
|
||||
})
|
||||
|
||||
it('handles fetch error', async () => {
|
||||
wrapper = Wrapper()
|
||||
mocks.$apollo.query.mockRejectedValue(new Error('Network error'))
|
||||
await wrapper.vm.fetchMessages({ room: mockRoom(), options: {} })
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith('Network error')
|
||||
expect(wrapper.vm.messages).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatMessageAdded with new room', () => {
|
||||
it('returns early when server returns empty rooms', async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.rooms = []
|
||||
mocks.$apollo.query.mockResolvedValue({ data: { Room: [] } })
|
||||
await subscriptionHandlers.chatMessageAdded.next({
|
||||
data: { chatMessageAdded: mockMessage({ room: { id: 'unknown' } }) },
|
||||
})
|
||||
expect(wrapper.vm.rooms).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns early when room fetch fails', async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.rooms = []
|
||||
mocks.$apollo.query.mockRejectedValue(new Error('fail'))
|
||||
await subscriptionHandlers.chatMessageAdded.next({
|
||||
data: { chatMessageAdded: mockMessage({ room: { id: 'unknown' } }) },
|
||||
})
|
||||
expect(wrapper.vm.rooms).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fetches and inserts new room without auto-select', async () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.rooms = [mockRoom()]
|
||||
wrapper.vm.selectedRoom = mockRoom()
|
||||
wrapper.vm.activeRoomId = 'room-1'
|
||||
const newRoomData = mockRoom({
|
||||
id: 'new-room',
|
||||
roomId: 'new-room',
|
||||
users: [
|
||||
{ _id: 'current-user', id: 'current-user', name: 'Me', avatar: null },
|
||||
{ _id: 'sender', id: 'sender', name: 'Sender', avatar: null },
|
||||
],
|
||||
})
|
||||
mocks.$apollo.query.mockResolvedValue({ data: { Room: [newRoomData] } })
|
||||
await subscriptionHandlers.chatMessageAdded.next({
|
||||
data: {
|
||||
chatMessageAdded: mockMessage({
|
||||
room: { id: 'new-room' },
|
||||
senderId: 'sender',
|
||||
}),
|
||||
},
|
||||
})
|
||||
expect(wrapper.vm.rooms[0].id).toBe('new-room')
|
||||
expect(wrapper.vm._externalRoomIds.has('new-room')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleUserSearch', () => {
|
||||
it('emits toggle-user-search', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.toggleUserSearch()
|
||||
expect(wrapper.emitted('toggle-user-search')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('navigateToUserProfile', () => {
|
||||
it('navigates using messageUserProfile', () => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.vm.selectedRoom = mockRoom()
|
||||
wrapper.vm.rooms = [mockRoom()]
|
||||
wrapper.vm.navigateToUserProfile('other-user')
|
||||
expect(mocks.$router.push).toHaveBeenCalledWith({
|
||||
path: '/profile/other-user/other',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('newRoom with string userId', () => {
|
||||
it('fetches user profile and creates virtual room', async () => {
|
||||
wrapper = Wrapper()
|
||||
mocks.$apollo.query
|
||||
.mockResolvedValueOnce({ data: { User: [{ id: 'u1', name: 'Test User', avatar: null }] } })
|
||||
.mockResolvedValueOnce({ data: { Room: [] } })
|
||||
await wrapper.vm.newRoom('u1')
|
||||
expect(wrapper.vm.rooms[0].roomName).toBe('Test User')
|
||||
expect(wrapper.vm.rooms[0].id).toBe('temp-u1')
|
||||
})
|
||||
|
||||
it('falls back to userId as name on profile fetch error', async () => {
|
||||
wrapper = Wrapper()
|
||||
mocks.$apollo.query
|
||||
.mockRejectedValueOnce(new Error('fail'))
|
||||
.mockResolvedValueOnce({ data: { Room: [] } })
|
||||
await wrapper.vm.newRoom('u1')
|
||||
expect(wrapper.vm.rooms[0].roomName).toBe('u1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendMessage virtual room replacement', () => {
|
||||
it('replaces virtual room with real room after send', async () => {
|
||||
wrapper = Wrapper()
|
||||
const virtualRoom = mockRoom({ id: 'temp-u1', roomId: 'temp-u1', _virtualUserId: 'u1' })
|
||||
wrapper.vm.rooms = [virtualRoom]
|
||||
wrapper.vm.selectedRoom = virtualRoom
|
||||
wrapper.vm.activeRoomId = 'temp-u1'
|
||||
const realRoom = mockRoom({ id: 'real-1', roomId: 'real-1' })
|
||||
mocks.$apollo.mutate.mockResolvedValue({
|
||||
data: { CreateMessage: { id: 'm1', room: { id: 'real-1' } } },
|
||||
})
|
||||
mocks.$apollo.query.mockResolvedValue({ data: { Room: [realRoom] } })
|
||||
await wrapper.vm.sendMessage({ roomId: 'temp-u1', content: 'hi', files: [] })
|
||||
expect(wrapper.vm.rooms.some((r) => r.id === 'real-1')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
<vue-advanced-chat
|
||||
:theme="theme"
|
||||
:current-user-id="currentUser.id"
|
||||
:room-id="computedRoomId"
|
||||
:template-actions="JSON.stringify(templatesText)"
|
||||
:menu-actions="JSON.stringify(menuActions)"
|
||||
:room-id="activeRoomId"
|
||||
:template-actions="EMPTY_ACTIONS"
|
||||
:menu-actions="EMPTY_ACTIONS"
|
||||
:text-messages="JSON.stringify(textMessages)"
|
||||
:message-actions="messageActions"
|
||||
:message-actions="EMPTY_ACTIONS"
|
||||
:messages="JSON.stringify(messages)"
|
||||
:messages-loaded="messagesLoaded"
|
||||
:rooms="JSON.stringify(rooms)"
|
||||
:room-actions="JSON.stringify(roomActions)"
|
||||
:room-actions="EMPTY_ACTIONS"
|
||||
:rooms-loaded="roomsLoaded"
|
||||
:loading-rooms="loadingRooms"
|
||||
:media-preview-enabled="isSafari ? 'false' : 'true'"
|
||||
@ -20,12 +20,14 @@
|
||||
:height="chatHeight"
|
||||
:styles="JSON.stringify(computedChatStyle)"
|
||||
:show-footer="true"
|
||||
:responsive-breakpoint="responsiveBreakpoint"
|
||||
:responsive-breakpoint="600"
|
||||
:single-room="singleRoom"
|
||||
show-reaction-emojis="false"
|
||||
custom-search-room-enabled="true"
|
||||
@send-message="sendMessage($event.detail[0])"
|
||||
@fetch-messages="fetchMessages($event.detail[0])"
|
||||
@fetch-more-rooms="fetchRooms"
|
||||
@fetch-more-rooms="fetchMoreRooms"
|
||||
@search-room="searchRooms($event.detail[0])"
|
||||
@add-room="toggleUserSearch"
|
||||
@open-user-tag="redirectToUserProfile($event.detail[0])"
|
||||
@open-file="openFile($event.detail[0].file.file)"
|
||||
@ -69,65 +71,65 @@
|
||||
</div>
|
||||
|
||||
<div slot="room-header-avatar">
|
||||
<nuxt-link v-if="roomHeaderLink" :to="roomHeaderLink" class="chat-header-profile-link">
|
||||
<component
|
||||
:is="roomHeaderLink ? 'nuxt-link' : 'span'"
|
||||
:to="roomHeaderLink"
|
||||
class="chat-header-profile-link"
|
||||
>
|
||||
<profile-avatar
|
||||
v-if="selectedRoom && selectedRoom.isGroupRoom"
|
||||
:profile="selectedRoom.groupProfile"
|
||||
v-if="selectedRoom"
|
||||
:profile="selectedRoomProfile"
|
||||
class="vac-avatar-profile"
|
||||
size="small"
|
||||
/>
|
||||
<div
|
||||
v-else-if="selectedRoom && selectedRoom.avatar"
|
||||
class="vac-avatar"
|
||||
:style="{ 'background-image': `url('${selectedRoom.avatar}')` }"
|
||||
/>
|
||||
<div v-else-if="selectedRoom" class="vac-avatar">
|
||||
<span class="initials">{{ getInitialsName(selectedRoom.roomName) }}</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<template v-else>
|
||||
<profile-avatar
|
||||
v-if="selectedRoom && selectedRoom.isGroupRoom"
|
||||
:profile="selectedRoom.groupProfile"
|
||||
class="vac-avatar-profile"
|
||||
size="small"
|
||||
/>
|
||||
<div
|
||||
v-else-if="selectedRoom && selectedRoom.avatar"
|
||||
class="vac-avatar"
|
||||
:style="{ 'background-image': `url('${selectedRoom.avatar}')` }"
|
||||
/>
|
||||
<div v-else-if="selectedRoom" class="vac-avatar">
|
||||
<span class="initials">{{ getInitialsName(selectedRoom.roomName) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</component>
|
||||
</div>
|
||||
|
||||
<div slot="room-header-info">
|
||||
<div class="vac-room-name vac-text-ellipsis">
|
||||
<nuxt-link v-if="roomHeaderLink" :to="roomHeaderLink" class="chat-header-profile-link">
|
||||
<component
|
||||
:is="roomHeaderLink ? 'nuxt-link' : 'span'"
|
||||
:to="roomHeaderLink"
|
||||
class="chat-header-profile-link"
|
||||
>
|
||||
<os-icon
|
||||
v-if="selectedRoom && selectedRoom.isGroupRoom"
|
||||
:icon="icons.group"
|
||||
class="room-group-icon"
|
||||
/>
|
||||
{{ selectedRoom ? selectedRoom.roomName : '' }}
|
||||
</nuxt-link>
|
||||
<span v-else>{{ selectedRoom ? selectedRoom.roomName : '' }}</span>
|
||||
</component>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="room in groupRooms"
|
||||
:slot="'room-list-info_' + room.roomId"
|
||||
:key="'info-' + room.id"
|
||||
>
|
||||
<div class="vac-room-name vac-text-ellipsis room-name-with-icon">
|
||||
<os-icon :icon="icons.group" class="room-group-icon" />
|
||||
{{ room.roomName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="room in rooms" :slot="'room-list-avatar_' + room.id" :key="room.id">
|
||||
<profile-avatar
|
||||
v-if="room.isGroupRoom"
|
||||
:profile="room.groupProfile"
|
||||
:profile="room.isGroupRoom ? room.groupProfile : room.userProfile"
|
||||
class="vac-avatar-profile"
|
||||
size="small"
|
||||
/>
|
||||
<div
|
||||
v-else-if="room.avatar"
|
||||
class="vac-avatar"
|
||||
:style="{ 'background-image': `url('${room.avatar}')` }"
|
||||
/>
|
||||
<div v-else class="vac-avatar">
|
||||
<span class="initials">{{ getInitialsName(room.roomName) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="msg in messages">
|
||||
<profile-avatar
|
||||
v-if="msg.avatar"
|
||||
:slot="'message-avatar_' + msg._id"
|
||||
:key="'avatar-' + msg._id"
|
||||
:profile="messageUserProfile(msg.senderId)"
|
||||
class="vac-message-avatar"
|
||||
style="align-self: flex-end; margin: 0 0 2px; cursor: pointer"
|
||||
@click.native="navigateToUserProfile(msg.senderId)"
|
||||
/>
|
||||
</template>
|
||||
</vue-advanced-chat>
|
||||
</template>
|
||||
|
||||
@ -136,8 +138,7 @@ import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
|
||||
import locales from '~/locales/index.js'
|
||||
import gql from 'graphql-tag'
|
||||
import { roomQuery, createGroupRoom, unreadRoomsQuery } from '~/graphql/Rooms'
|
||||
import { roomQuery, createGroupRoom, unreadRoomsQuery, userProfileQuery } from '~/graphql/Rooms'
|
||||
import {
|
||||
messageQuery,
|
||||
createMessageMutation,
|
||||
@ -148,6 +149,10 @@ import {
|
||||
import chatStyle from '~/constants/chat.js'
|
||||
import { mapGetters, mapMutations } from 'vuex'
|
||||
|
||||
const EMPTY_ACTIONS = JSON.stringify([])
|
||||
const ROOM_PAGE_SIZE = 10
|
||||
const MESSAGE_PAGE_SIZE = 20
|
||||
|
||||
export default {
|
||||
name: 'Chat',
|
||||
components: { OsButton, OsIcon, ProfileAvatar },
|
||||
@ -171,20 +176,16 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
menuActions: [],
|
||||
messageActions: [],
|
||||
templatesText: [],
|
||||
roomActions: [],
|
||||
responsiveBreakpoint: 600,
|
||||
EMPTY_ACTIONS,
|
||||
rooms: [],
|
||||
roomsLoaded: false,
|
||||
roomCursor: null,
|
||||
roomPageSize: 10,
|
||||
roomSearch: '',
|
||||
roomObserverDirty: false,
|
||||
selectedRoom: null,
|
||||
activeRoomId: null,
|
||||
loadingRooms: true,
|
||||
messagesLoaded: false,
|
||||
messagePageSize: 20,
|
||||
oldestLoadedIndexId: null,
|
||||
messages: [],
|
||||
unseenMessageIds: new Set(),
|
||||
@ -240,6 +241,7 @@ export default {
|
||||
beforeDestroy() {
|
||||
this._subscriptions?.forEach((s) => s.unsubscribe())
|
||||
if (this._intersectionObserver) this._intersectionObserver.disconnect()
|
||||
if (this._roomLoaderObserver) this._roomLoaderObserver.disconnect()
|
||||
if (this._mutationObserver) this._mutationObserver.disconnect()
|
||||
if (this._seenFlushTimer) clearTimeout(this._seenFlushTimer)
|
||||
},
|
||||
@ -249,17 +251,11 @@ export default {
|
||||
}),
|
||||
chatHeight() {
|
||||
if (this.singleRoom) return 'calc(100dvh - 190px)'
|
||||
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
|
||||
return '100%'
|
||||
}
|
||||
return 'calc(100dvh - 190px)'
|
||||
return '100%'
|
||||
},
|
||||
computedChatStyle() {
|
||||
return chatStyle.STYLE.light
|
||||
},
|
||||
computedRoomId() {
|
||||
return this.activeRoomId || null
|
||||
},
|
||||
isSafari() {
|
||||
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
||||
},
|
||||
@ -279,6 +275,15 @@ export default {
|
||||
}
|
||||
return { name: 'chat', query }
|
||||
},
|
||||
groupRooms() {
|
||||
return this.rooms.filter((r) => r.isGroupRoom)
|
||||
},
|
||||
selectedRoomProfile() {
|
||||
if (!this.selectedRoom) return null
|
||||
return this.selectedRoom.isGroupRoom
|
||||
? this.selectedRoom.groupProfile
|
||||
: this.selectedRoom.userProfile
|
||||
},
|
||||
roomHeaderLink() {
|
||||
if (!this.selectedRoom) return null
|
||||
if (this.selectedRoom.isGroupRoom && this.selectedRoom.groupProfile?.id) {
|
||||
@ -314,6 +319,18 @@ export default {
|
||||
commitUnreadRoomCount: 'chat/UPDATE_ROOM_COUNT',
|
||||
}),
|
||||
|
||||
buildLastMessage(msg) {
|
||||
const content = (msg.content || '').trim()
|
||||
let preview = content ? content.substring(0, 30) : ''
|
||||
if (!preview && msg.files?.length) {
|
||||
const f = msg.files[0]
|
||||
if (!f.type?.startsWith('audio/') && !f.audio) {
|
||||
preview = `\uD83D\uDCCE ${f.name || ''}`
|
||||
}
|
||||
}
|
||||
return { ...msg, content: preview, files: msg.files }
|
||||
},
|
||||
|
||||
markAsSeen(messageIds) {
|
||||
if (!messageIds.length || !this.selectedRoom) return
|
||||
const room = this.selectedRoom
|
||||
@ -330,7 +347,7 @@ export default {
|
||||
if (roomIndex !== -1) {
|
||||
const changedRoom = { ...this.rooms[roomIndex] }
|
||||
changedRoom.unreadCount = Math.max(0, changedRoom.unreadCount - messageIds.length)
|
||||
this.rooms[roomIndex] = changedRoom
|
||||
this.$set(this.rooms, roomIndex, changedRoom)
|
||||
}
|
||||
// Persist to server
|
||||
this.$apollo
|
||||
@ -419,12 +436,26 @@ export default {
|
||||
this.messages = filtered
|
||||
},
|
||||
|
||||
messageUserProfile(senderId) {
|
||||
const room = this.rooms.find((r) => r.id === this.selectedRoom?.id)
|
||||
const profile = room?._userProfiles?.[senderId]
|
||||
if (profile) return profile
|
||||
const user = this.selectedRoom?.users?.find((u) => u._id === senderId || u.id === senderId)
|
||||
return user ? { id: user.id, name: user.username || user.name } : { name: senderId }
|
||||
},
|
||||
|
||||
initialsAvatarUrl(name) {
|
||||
const initials = (name || '?').match(/\b\w/g)?.join('').substring(0, 2).toUpperCase() || '?'
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56"><circle cx="28" cy="28" r="28" fill="rgb(25,122,49)"/><text x="50%25" y="50%25" dominant-baseline="central" text-anchor="middle" fill="white" font-family="sans-serif" font-size="22" font-weight="500">${initials}</text></svg>`
|
||||
return `data:image/svg+xml,${svg}`
|
||||
},
|
||||
|
||||
prepareMessage(msg) {
|
||||
const m = { ...msg }
|
||||
m.content = m.content || ''
|
||||
if (!m._rawDate) m._rawDate = m.date
|
||||
this.formatMessageDate(m)
|
||||
m.avatar = m.avatar?.w320 || m.avatar || null
|
||||
m.avatar = m.avatar?.w320 || m.avatar || this.initialsAvatarUrl(m.username)
|
||||
if (!m._originalAvatar) m._originalAvatar = m.avatar
|
||||
return m
|
||||
},
|
||||
@ -509,12 +540,12 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
async fetchRooms({ room } = {}) {
|
||||
async fetchRooms({ room, search, replace, generation } = {}) {
|
||||
this.roomsLoaded = false
|
||||
try {
|
||||
const variables = room?.id
|
||||
? { id: room.id }
|
||||
: { first: this.roomPageSize, before: this.roomCursor }
|
||||
: { first: ROOM_PAGE_SIZE, before: this.roomCursor, ...(search && { search }) }
|
||||
|
||||
const {
|
||||
data: { Room },
|
||||
@ -524,34 +555,89 @@ export default {
|
||||
fetchPolicy: 'no-cache',
|
||||
})
|
||||
|
||||
const existingIds = new Set(this.rooms.map((r) => r.id))
|
||||
const newRooms = Room.filter((r) => !existingIds.has(r.id)).map((r) =>
|
||||
this.fixRoomObject(r),
|
||||
)
|
||||
this.rooms = [...this.rooms, ...newRooms]
|
||||
if (generation != null && generation !== this.roomSearchGeneration) return
|
||||
|
||||
if (replace) {
|
||||
this.rooms = Room.map((r) => this.fixRoomObject(r))
|
||||
} else {
|
||||
const existingIds = new Set(this.rooms.map((r) => r.id))
|
||||
const newRooms = Room.filter((r) => !existingIds.has(r.id)).map((r) =>
|
||||
this.fixRoomObject(r),
|
||||
)
|
||||
this.rooms = [...this.rooms, ...newRooms]
|
||||
}
|
||||
|
||||
if (!room?.id && Room.length > 0) {
|
||||
// Update cursor to the oldest room's sort date
|
||||
const lastRoom = Room[Room.length - 1]
|
||||
this.roomCursor = lastRoom.lastMessageAt || lastRoom.createdAt
|
||||
}
|
||||
|
||||
if (Room.length < this.roomPageSize) {
|
||||
if (Room.length < ROOM_PAGE_SIZE) {
|
||||
this.roomsLoaded = true
|
||||
}
|
||||
|
||||
if (this.singleRoom && this.rooms.length > 0) {
|
||||
this.selectRoom(this.rooms[0])
|
||||
this.activeRoomId = this.rooms[0].roomId
|
||||
}
|
||||
} catch (error) {
|
||||
this.rooms = []
|
||||
this.$toast.error(error.message)
|
||||
}
|
||||
// must be set false after initial rooms are loaded and never changed again
|
||||
this.loadingRooms = false
|
||||
},
|
||||
|
||||
async searchRooms(event) {
|
||||
const value = typeof event === 'string' ? event : event?.value
|
||||
this.roomSearch = value || ''
|
||||
this.roomCursor = null
|
||||
if (this.roomsLoaded) this.roomObserverDirty = true
|
||||
this.roomSearchGeneration = (this.roomSearchGeneration || 0) + 1
|
||||
const generation = this.roomSearchGeneration
|
||||
await this.fetchRooms({ search: this.roomSearch || undefined, replace: true, generation })
|
||||
if (generation !== this.roomSearchGeneration) return
|
||||
// Re-init IntersectionObserver after it was disabled by roomsLoaded=true
|
||||
if (this.roomObserverDirty && !this.roomsLoaded) {
|
||||
this.roomObserverDirty = false
|
||||
this.$nextTick(() => this.reinitRoomLoader())
|
||||
}
|
||||
},
|
||||
|
||||
reinitRoomLoader() {
|
||||
const shadow = this.$el?.shadowRoot
|
||||
if (!shadow) return
|
||||
const loader = shadow.querySelector('#infinite-loader-rooms')
|
||||
const roomsList = shadow.querySelector('#rooms-list')
|
||||
if (!loader || !roomsList) return
|
||||
// Make loader visible (library hides it via v-show when showLoader=false)
|
||||
loader.style.display = ''
|
||||
// Create a new IntersectionObserver to trigger pagination on scroll
|
||||
if (this._roomLoaderObserver) this._roomLoaderObserver.disconnect()
|
||||
this._roomLoaderObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !this.roomsLoaded) {
|
||||
this.fetchMoreRooms()
|
||||
}
|
||||
},
|
||||
{ root: roomsList, threshold: 0 },
|
||||
)
|
||||
this._roomLoaderObserver.observe(loader)
|
||||
},
|
||||
|
||||
fetchMoreRooms() {
|
||||
this.fetchRooms({ search: this.roomSearch || undefined })
|
||||
},
|
||||
|
||||
async fetchMessages({ room, options = {} }) {
|
||||
if (!room?.roomId) return
|
||||
// When an external socket message added a new room, the library may try to
|
||||
// auto-select it. Lock activeRoomId to the current room to prevent focus steal.
|
||||
if (this._externalRoomIds?.has(room.roomId)) {
|
||||
this._externalRoomIds.delete(room.roomId)
|
||||
if (this.selectedRoom) {
|
||||
this.activeRoomId = this.selectedRoom.roomId
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.selectedRoom?.id !== room.id) {
|
||||
this.messages = []
|
||||
this.oldestLoadedIndexId = null
|
||||
@ -570,7 +656,7 @@ export default {
|
||||
this.messagesLoaded = options.refetch ? this.messagesLoaded : false
|
||||
const variables = {
|
||||
roomId: room.id,
|
||||
first: this.messagePageSize,
|
||||
first: MESSAGE_PAGE_SIZE,
|
||||
}
|
||||
if (!options.refetch && this.oldestLoadedIndexId !== null) {
|
||||
variables.beforeIndex = this.oldestLoadedIndexId
|
||||
@ -596,7 +682,7 @@ export default {
|
||||
this.oldestLoadedIndexId = oldestMsg.indexId
|
||||
}
|
||||
}
|
||||
if (Message.length < this.messagePageSize) {
|
||||
if (Message.length < MESSAGE_PAGE_SIZE) {
|
||||
this.messagesLoaded = true
|
||||
}
|
||||
// Ensure visibility tracking is running
|
||||
@ -610,6 +696,7 @@ export default {
|
||||
async chatMessageAdded({ data }) {
|
||||
const msg = data.chatMessageAdded
|
||||
let roomIndex = this.rooms.findIndex((r) => r.id === msg.room.id)
|
||||
let freshlyFetched = false
|
||||
if (roomIndex === -1) {
|
||||
// Room not in list yet — fetch it specifically
|
||||
try {
|
||||
@ -622,8 +709,11 @@ export default {
|
||||
})
|
||||
if (Room?.length) {
|
||||
const newRoom = this.fixRoomObject(Room[0])
|
||||
if (!this._externalRoomIds) this._externalRoomIds = new Set()
|
||||
this._externalRoomIds.add(newRoom.roomId)
|
||||
this.rooms = [newRoom, ...this.rooms]
|
||||
roomIndex = 0
|
||||
freshlyFetched = true
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@ -632,19 +722,16 @@ export default {
|
||||
}
|
||||
}
|
||||
const changedRoom = { ...this.rooms[roomIndex] }
|
||||
changedRoom.lastMessage = {
|
||||
...msg,
|
||||
content: (msg.content || '').trim().substring(0, 30),
|
||||
}
|
||||
changedRoom.lastMessage = this.buildLastMessage(msg)
|
||||
changedRoom.lastMessageAt = msg.date
|
||||
changedRoom.index = new Date().toISOString()
|
||||
const isCurrentRoom = msg.room.id === this.selectedRoom?.id
|
||||
const isOwnMessage = msg.senderId === this.currentUser.id
|
||||
if (!isCurrentRoom && !isOwnMessage) {
|
||||
// Don't increment unreadCount for freshly fetched rooms — server count already includes this message
|
||||
if (!freshlyFetched && !isCurrentRoom && !isOwnMessage) {
|
||||
changedRoom.unreadCount++
|
||||
}
|
||||
// Reassign array to trigger Vue reactivity and vue-advanced-chat re-sort
|
||||
this.rooms = [changedRoom, ...this.rooms.filter((r) => r.id !== msg.room.id)]
|
||||
this.moveRoomToTop(changedRoom, msg.room.id)
|
||||
// Only add incoming messages to the chat — own messages are handled via mutation response
|
||||
if (isCurrentRoom && !isOwnMessage) {
|
||||
this.addSocketMessage(msg)
|
||||
@ -688,11 +775,7 @@ export default {
|
||||
...room,
|
||||
lastMessage: { ...room.lastMessage, ...statusUpdate },
|
||||
}
|
||||
this.rooms = [
|
||||
...this.rooms.slice(0, roomIndex),
|
||||
changedRoom,
|
||||
...this.rooms.slice(roomIndex + 1),
|
||||
]
|
||||
this.$set(this.rooms, roomIndex, changedRoom)
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -707,12 +790,13 @@ export default {
|
||||
? files.map((file) => ({
|
||||
upload: new File(
|
||||
[file.blob],
|
||||
// Captured audio already has the right extension in the name
|
||||
file.extension ? `${file.name}.${file.extension}` : file.name,
|
||||
{ type: file.type },
|
||||
),
|
||||
name: file.name,
|
||||
extension: file.extension || undefined,
|
||||
type: file.type,
|
||||
...(file.duration != null && { duration: file.duration }),
|
||||
}))
|
||||
: null
|
||||
|
||||
@ -724,7 +808,8 @@ export default {
|
||||
saved: true,
|
||||
_rawDate: new Date().toISOString(),
|
||||
_originalAvatar:
|
||||
this.selectedRoom?.users?.find((u) => u.id === this.currentUser.id)?.avatar || null,
|
||||
this.selectedRoom?.users?.find((u) => u.id === this.currentUser.id)?.avatar ||
|
||||
this.initialsAvatarUrl(this.currentUser.name),
|
||||
senderId: this.currentUser.id,
|
||||
files:
|
||||
messageDetails.files?.map((file) => ({
|
||||
@ -741,12 +826,10 @@ export default {
|
||||
const roomIndex = this.rooms.findIndex((r) => r.id === roomId)
|
||||
if (roomIndex !== -1) {
|
||||
const changedRoom = { ...this.rooms[roomIndex] }
|
||||
changedRoom.lastMessage.content = (content || '').trim().substring(0, 30)
|
||||
changedRoom.lastMessage = this.buildLastMessage({ content, files })
|
||||
changedRoom.index = new Date().toISOString()
|
||||
this.rooms = [changedRoom, ...this.rooms.filter((r) => r.id !== roomId)]
|
||||
this.$nextTick(() => {
|
||||
this.scrollRoomsListToTop()
|
||||
})
|
||||
this.moveRoomToTop(changedRoom, roomId)
|
||||
this.$nextTick(() => this.scrollRoomsListToTop())
|
||||
}
|
||||
|
||||
try {
|
||||
@ -797,11 +880,6 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
getInitialsName(fullname) {
|
||||
if (!fullname) return
|
||||
return fullname.match(/\b\w/g).join('').substring(0, 3).toUpperCase()
|
||||
},
|
||||
|
||||
toggleUserSearch() {
|
||||
this.$emit('toggle-user-search')
|
||||
},
|
||||
@ -812,7 +890,7 @@ export default {
|
||||
const fixedRoom = {
|
||||
...room,
|
||||
isGroupRoom,
|
||||
// For group rooms: provide group profile data for ProfileAvatar component
|
||||
// Profile data for ProfileAvatar component
|
||||
groupProfile: isGroupRoom
|
||||
? {
|
||||
id: room.group?.id,
|
||||
@ -821,111 +899,121 @@ export default {
|
||||
avatar: room.group?.avatar,
|
||||
}
|
||||
: null,
|
||||
userProfile: null,
|
||||
index: room.lastMessage ? room.lastMessage.date : room.createdAt,
|
||||
avatar: room.avatar?.w320 || room.avatar,
|
||||
lastMessage: room.lastMessage
|
||||
? {
|
||||
...room.lastMessage,
|
||||
content: (room.lastMessage?.content || '').trim().substring(0, 30),
|
||||
}
|
||||
: { content: '' },
|
||||
lastMessage: room.lastMessage ? this.buildLastMessage(room.lastMessage) : { content: '' },
|
||||
users: room.users.map((u) => {
|
||||
return { ...u, username: u.name, avatar: u.avatar?.w320 }
|
||||
}),
|
||||
}
|
||||
if (!fixedRoom.avatar) {
|
||||
if (isGroupRoom) {
|
||||
// Build user profiles from original room.users (before avatar was flattened to string)
|
||||
const userProfiles = {}
|
||||
for (const u of room.users) {
|
||||
userProfiles[u.id] = { id: u.id, name: u.name, avatar: u.avatar }
|
||||
}
|
||||
fixedRoom._userProfiles = userProfiles
|
||||
if (isGroupRoom) {
|
||||
if (!fixedRoom.avatar) {
|
||||
fixedRoom.avatar = room.group?.avatar?.w320 || room.group?.avatar || null
|
||||
} else {
|
||||
// as long as we cannot query avatar on CreateRoom
|
||||
const otherUser = fixedRoom.users.find((u) => u.id !== this.currentUser.id)
|
||||
fixedRoom.avatar = otherUser?.avatar
|
||||
}
|
||||
} else {
|
||||
const otherUser = room.users.find((u) => u.id !== this.currentUser.id)
|
||||
fixedRoom.userProfile = otherUser
|
||||
? userProfiles[otherUser.id]
|
||||
: { name: fixedRoom.roomName }
|
||||
if (!fixedRoom.avatar) {
|
||||
fixedRoom.avatar = otherUser?.avatar?.w320 || null
|
||||
}
|
||||
}
|
||||
return fixedRoom
|
||||
},
|
||||
|
||||
selectRoom(room) {
|
||||
this.activeRoomId = room.roomId
|
||||
moveRoomToTop(room, roomId) {
|
||||
const id = roomId || room.id
|
||||
this.rooms = [room, ...this.rooms.filter((r) => r.id !== id)]
|
||||
},
|
||||
|
||||
bringRoomToTopAndSelect(room) {
|
||||
room.index = new Date().toISOString()
|
||||
this.rooms = [room, ...this.rooms.filter((r) => r.id !== room.id)]
|
||||
this.$nextTick(() => this.selectRoom(room))
|
||||
this.moveRoomToTop(room)
|
||||
this.$nextTick(() => {
|
||||
this.activeRoomId = room.roomId
|
||||
})
|
||||
},
|
||||
|
||||
findLocalRoom(predicate) {
|
||||
const room = this.rooms.find(predicate)
|
||||
if (room) this.bringRoomToTopAndSelect(room)
|
||||
return room
|
||||
},
|
||||
|
||||
async fetchServerRoom(variables) {
|
||||
try {
|
||||
const {
|
||||
data: { Room },
|
||||
} = await this.$apollo.query({
|
||||
query: roomQuery(),
|
||||
variables,
|
||||
fetchPolicy: 'no-cache',
|
||||
})
|
||||
if (Room?.length) {
|
||||
const room = this.fixRoomObject(Room[0])
|
||||
this.bringRoomToTopAndSelect(room)
|
||||
return room
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
async newRoom(userOrId) {
|
||||
// Accept either a user object { id, name } or just a userId string
|
||||
const userId = typeof userOrId === 'string' ? userOrId : userOrId.id
|
||||
let userName = typeof userOrId === 'string' ? null : userOrId.name
|
||||
let userAvatar =
|
||||
typeof userOrId === 'string' ? null : userOrId.avatar?.w320 || userOrId.avatar?.url || null
|
||||
let userAvatarObj = typeof userOrId === 'string' ? null : userOrId.avatar
|
||||
let userAvatar = userAvatarObj?.w320 || userAvatarObj?.url || null
|
||||
|
||||
// When called with just an ID (e.g. from query params), fetch user profile
|
||||
if (typeof userOrId === 'string') {
|
||||
try {
|
||||
const { data } = await this.$apollo.query({
|
||||
query: gql`
|
||||
query ($id: ID!) {
|
||||
User(id: $id) {
|
||||
id
|
||||
name
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
query: userProfileQuery(),
|
||||
variables: { id: userId },
|
||||
fetchPolicy: 'no-cache',
|
||||
})
|
||||
const user = data.User?.[0]
|
||||
if (user) {
|
||||
userName = user.name
|
||||
userAvatar = user.avatar?.url || null
|
||||
userAvatarObj = user.avatar
|
||||
userAvatar = user.avatar?.w320 || user.avatar?.url || null
|
||||
}
|
||||
} catch {
|
||||
// Fall through with userId as display name
|
||||
// Fall through
|
||||
}
|
||||
if (!userName) userName = userId
|
||||
}
|
||||
|
||||
// Check if a DM room with this user already exists locally
|
||||
const existingRoom = this.rooms.find(
|
||||
(r) => !r.isGroupRoom && r.users.some((u) => u.id === userId),
|
||||
)
|
||||
if (existingRoom) {
|
||||
this.bringRoomToTopAndSelect(existingRoom)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if a DM room with this user exists on the server (not yet loaded locally)
|
||||
try {
|
||||
const {
|
||||
data: { Room },
|
||||
} = await this.$apollo.query({
|
||||
query: roomQuery(),
|
||||
variables: { userId },
|
||||
fetchPolicy: 'no-cache',
|
||||
})
|
||||
const serverRoom = Room?.[0]
|
||||
if (serverRoom) {
|
||||
const room = this.fixRoomObject(serverRoom)
|
||||
this.bringRoomToTopAndSelect(room)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to virtual room creation
|
||||
}
|
||||
if (this.findLocalRoom((r) => !r.isGroupRoom && r.users.some((u) => u.id === userId))) return
|
||||
if (await this.fetchServerRoom({ userId })) return
|
||||
|
||||
// Create a virtual room (no backend call — room is created on first message)
|
||||
const currentUserProfile = {
|
||||
id: this.currentUser.id,
|
||||
name: this.currentUser.name,
|
||||
avatar: this.currentUser.avatar,
|
||||
}
|
||||
const otherUserProfile = { id: userId, name: userName, avatar: userAvatarObj }
|
||||
const virtualRoom = {
|
||||
id: `temp-${userId}`,
|
||||
roomId: `temp-${userId}`,
|
||||
roomName: userName,
|
||||
isGroupRoom: false,
|
||||
groupProfile: null,
|
||||
userProfile: otherUserProfile,
|
||||
_userProfiles: {
|
||||
[this.currentUser.id]: currentUserProfile,
|
||||
[userId]: otherUserProfile,
|
||||
},
|
||||
avatar: userAvatar,
|
||||
lastMessageAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
@ -933,43 +1021,27 @@ export default {
|
||||
index: new Date().toISOString(),
|
||||
lastMessage: { content: '' },
|
||||
users: [
|
||||
{ _id: this.currentUser.id, id: this.currentUser.id, username: this.currentUser.name },
|
||||
{ _id: userId, id: userId, username: userName, avatar: userAvatar },
|
||||
{
|
||||
_id: this.currentUser.id,
|
||||
id: this.currentUser.id,
|
||||
name: this.currentUser.name,
|
||||
username: this.currentUser.name,
|
||||
},
|
||||
{ _id: userId, id: userId, name: userName, username: userName, avatar: userAvatar },
|
||||
],
|
||||
_virtualUserId: userId,
|
||||
}
|
||||
this.rooms = [virtualRoom, ...this.rooms]
|
||||
this.loadingRooms = false
|
||||
this.$nextTick(() => this.selectRoom(virtualRoom))
|
||||
this.$nextTick(() => {
|
||||
this.activeRoomId = virtualRoom.roomId
|
||||
})
|
||||
},
|
||||
|
||||
async newGroupRoom(groupId) {
|
||||
// Check if the group room already exists locally
|
||||
const existingRoom = this.rooms.find((r) => r.isGroupRoom && r.groupProfile?.id === groupId)
|
||||
if (existingRoom) {
|
||||
this.bringRoomToTopAndSelect(existingRoom)
|
||||
return
|
||||
}
|
||||
if (this.findLocalRoom((r) => r.isGroupRoom && r.groupProfile?.id === groupId)) return
|
||||
if (await this.fetchServerRoom({ groupId })) return
|
||||
|
||||
// Check if the group room exists on the server (not yet loaded locally)
|
||||
try {
|
||||
const {
|
||||
data: { Room },
|
||||
} = await this.$apollo.query({
|
||||
query: roomQuery(),
|
||||
variables: { groupId },
|
||||
fetchPolicy: 'no-cache',
|
||||
})
|
||||
if (Room?.length) {
|
||||
const room = this.fixRoomObject(Room[0])
|
||||
this.bringRoomToTopAndSelect(room)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to creation
|
||||
}
|
||||
|
||||
// Room doesn't exist yet — create it
|
||||
try {
|
||||
const {
|
||||
data: { CreateGroupRoom },
|
||||
@ -1012,42 +1084,44 @@ export default {
|
||||
document.body.removeChild(downloadLink)
|
||||
},
|
||||
|
||||
navigateToUserProfile(userId) {
|
||||
const profile = this.messageUserProfile(userId)
|
||||
const slug = (profile.name || '').toLowerCase().replaceAll(' ', '-')
|
||||
this.$router.push({ path: `/profile/${userId}/${slug}` })
|
||||
},
|
||||
|
||||
redirectToUserProfile({ user }) {
|
||||
const userID = user.id
|
||||
const userName = user.name.toLowerCase().replaceAll(' ', '-')
|
||||
const url = `/profile/${userID}/${userName}`
|
||||
this.$router.push({ path: url })
|
||||
const slug = (user.name || '').toLowerCase().replaceAll(' ', '-')
|
||||
this.$router.push({ path: `/profile/${user.id}/${slug}` })
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.vac-avatar {
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-color: $color-primary-dark;
|
||||
color: $text-color-primary-inverse;
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
min-height: 42px;
|
||||
min-width: 42px;
|
||||
margin-right: 15px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
|
||||
> .initials {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.vac-avatar-profile {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.room-group-icon {
|
||||
vertical-align: middle !important;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.room-name-with-icon {
|
||||
color: var(--chat-room-color-username);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vac-message-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
font-size: 11px;
|
||||
align-self: flex-end;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.ds-flex-item.single-chat-bubble {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
@ -20,8 +20,8 @@ const STYLE = {
|
||||
|
||||
container: {
|
||||
border: 'none',
|
||||
borderRadius: styleData.borderRadiusBase,
|
||||
boxShadow: styleData.boxShadowBase,
|
||||
borderRadius: '0',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
|
||||
header: {
|
||||
|
||||
@ -24,8 +24,10 @@ export const createMessageMutation = () => {
|
||||
files {
|
||||
url
|
||||
name
|
||||
extension
|
||||
#size
|
||||
type
|
||||
duration
|
||||
#preview
|
||||
}
|
||||
}
|
||||
@ -63,10 +65,11 @@ export const messageQuery = () => {
|
||||
files {
|
||||
url
|
||||
name
|
||||
extension
|
||||
#size
|
||||
type
|
||||
#audio
|
||||
#duration
|
||||
duration
|
||||
#preview
|
||||
}
|
||||
}
|
||||
@ -98,10 +101,11 @@ export const chatMessageAdded = () => {
|
||||
files {
|
||||
url
|
||||
name
|
||||
extension
|
||||
#size
|
||||
type
|
||||
#audio
|
||||
#duration
|
||||
duration
|
||||
#preview
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,8 +37,15 @@ export const createGroupRoom = () => gql`
|
||||
export const roomQuery = () => gql`
|
||||
${imageUrls}
|
||||
|
||||
query Room($first: Int, $before: String, $id: ID, $userId: ID, $groupId: ID) {
|
||||
Room(first: $first, before: $before, id: $id, userId: $userId, groupId: $groupId) {
|
||||
query Room($first: Int, $before: String, $id: ID, $userId: ID, $groupId: ID, $search: String) {
|
||||
Room(
|
||||
first: $first
|
||||
before: $before
|
||||
id: $id
|
||||
userId: $userId
|
||||
groupId: $groupId
|
||||
search: $search
|
||||
) {
|
||||
id
|
||||
roomId
|
||||
roomName
|
||||
@ -66,6 +73,13 @@ export const roomQuery = () => gql`
|
||||
saved
|
||||
distributed
|
||||
seen
|
||||
files {
|
||||
url
|
||||
name
|
||||
extension
|
||||
type
|
||||
duration
|
||||
}
|
||||
}
|
||||
users {
|
||||
_id
|
||||
@ -79,6 +93,20 @@ export const roomQuery = () => gql`
|
||||
}
|
||||
`
|
||||
|
||||
export const userProfileQuery = () => gql`
|
||||
${imageUrls}
|
||||
|
||||
query ($id: ID!) {
|
||||
User(id: $id) {
|
||||
id
|
||||
name
|
||||
avatar {
|
||||
...imageUrls
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const unreadRoomsQuery = () => {
|
||||
return gql`
|
||||
query {
|
||||
|
||||
@ -141,7 +141,7 @@ export const searchHashtags = gql`
|
||||
export const searchChatTargets = gql`
|
||||
${imageUrls}
|
||||
|
||||
query ($query: String!, $limit: Int) {
|
||||
query searchChatTargets($query: String!, $limit: Int) {
|
||||
searchChatTargets(query: $query, limit: $limit) {
|
||||
__typename
|
||||
... on User {
|
||||
|
||||
@ -90,8 +90,33 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-default:has(.chat-page) .main-container {
|
||||
padding-bottom: 0 !important;
|
||||
.layout-default:has(.chat-page) {
|
||||
> .ds-container {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
padding-top: var(--header-height, 66px) !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-page {
|
||||
--footer-height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - var(--header-height, 66px) - var(--footer-height));
|
||||
overflow: hidden;
|
||||
|
||||
> * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@ -101,26 +126,10 @@ export default {
|
||||
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;
|
||||
}
|
||||
}
|
||||
.chat-page {
|
||||
--footer-height: 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user