fix(webapp): enhance group chat UX with avatars and file handling (#9485)

This commit is contained in:
Ulf Gebhardt 2026-04-05 20:27:44 +02:00 committed by GitHub
parent 5b5db947e7
commit 700aaaf0b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 991 additions and 264 deletions

View File

@ -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() },
}

View File

@ -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(':')})

View File

@ -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)
})
})
})

View File

@ -280,4 +280,8 @@ export default {
},
}),
},
File: {
extension: (parent: { extension?: string | null }) => parent.extension ?? null,
duration: (parent: { duration?: number | null }) => parent.duration ?? null,
},
}

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View 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

View 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

View 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"

View File

@ -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

View 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

View File

@ -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)
})

View File

@ -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')
})

View File

@ -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')
})

View File

@ -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()
})

View File

@ -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')
})

View File

@ -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 })
})

View File

@ -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)
})

View File

@ -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)
})

View File

@ -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')
})

View File

@ -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;

View File

@ -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()

View File

@ -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;

View File

@ -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)
})
})
})

View File

@ -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;
}

View File

@ -20,8 +20,8 @@ const STYLE = {
container: {
border: 'none',
borderRadius: styleData.borderRadiusBase,
boxShadow: styleData.boxShadowBase,
borderRadius: '0',
boxShadow: 'none',
},
header: {

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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>