diff --git a/backend/src/db/models/File.ts b/backend/src/db/models/File.ts index a247d1ac9..1788052c4 100644 --- a/backend/src/db/models/File.ts +++ b/backend/src/db/models/File.ts @@ -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() }, } diff --git a/backend/src/graphql/resolvers/attachments/attachments.ts b/backend/src/graphql/resolvers/attachments/attachments.ts index 37f381d5f..6909d5814 100644 --- a/backend/src/graphql/resolvers/attachments/attachments.ts +++ b/backend/src/graphql/resolvers/attachments/attachments.ts @@ -28,13 +28,17 @@ export interface AddAttachmentOpts { export interface FileInput { upload?: Promise 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(':')}) diff --git a/backend/src/graphql/resolvers/messages.spec.ts b/backend/src/graphql/resolvers/messages.spec.ts index 291c71697..543a1a87e 100644 --- a/backend/src/graphql/resolvers/messages.spec.ts +++ b/backend/src/graphql/resolvers/messages.spec.ts @@ -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) + }) + }) }) diff --git a/backend/src/graphql/resolvers/messages.ts b/backend/src/graphql/resolvers/messages.ts index 209f1d44a..336a6ef93 100644 --- a/backend/src/graphql/resolvers/messages.ts +++ b/backend/src/graphql/resolvers/messages.ts @@ -280,4 +280,8 @@ export default { }, }), }, + File: { + extension: (parent: { extension?: string | null }) => parent.extension ?? null, + duration: (parent: { duration?: number | null }) => parent.duration ?? null, + }, } diff --git a/backend/src/graphql/resolvers/rooms.ts b/backend/src/graphql/resolvers/rooms.ts index 5693fde79..5d49dbb0a 100644 --- a/backend/src/graphql/resolvers/rooms.ts +++ b/backend/src/graphql/resolvers/rooms.ts @@ -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) diff --git a/backend/src/graphql/types/type/File.gql b/backend/src/graphql/types/type/File.gql index 3bd7bd400..4c7296ca7 100644 --- a/backend/src/graphql/types/type/File.gql +++ b/backend/src/graphql/types/type/File.gql @@ -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 } diff --git a/backend/src/graphql/types/type/Room.gql b/backend/src/graphql/types/type/Room.gql index eef490098..dbaa23ab5 100644 --- a/backend/src/graphql/types/type/Room.gql +++ b/backend/src/graphql/types/type/Room.gql @@ -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 } diff --git a/cypress/e2e/chat/Avatar.feature b/cypress/e2e/chat/Avatar.feature new file mode 100644 index 000000000..9ca2141dc --- /dev/null +++ b/cypress/e2e/chat/Avatar.feature @@ -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 diff --git a/cypress/e2e/chat/ExternalRoom.feature b/cypress/e2e/chat/ExternalRoom.feature new file mode 100644 index 000000000..5dc21628a --- /dev/null +++ b/cypress/e2e/chat/ExternalRoom.feature @@ -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 diff --git a/cypress/e2e/chat/FileMessage.feature b/cypress/e2e/chat/FileMessage.feature new file mode 100644 index 000000000..2123ad213 --- /dev/null +++ b/cypress/e2e/chat/FileMessage.feature @@ -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" + diff --git a/cypress/e2e/chat/GroupChat.feature b/cypress/e2e/chat/GroupChat.feature index 7784cdee7..75e4bce1c 100644 --- a/cypress/e2e/chat/GroupChat.feature +++ b/cypress/e2e/chat/GroupChat.feature @@ -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 diff --git a/cypress/e2e/chat/RoomFilter.feature b/cypress/e2e/chat/RoomFilter.feature new file mode 100644 index 000000000..a578f991f --- /dev/null +++ b/cypress/e2e/chat/RoomFilter.feature @@ -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 diff --git a/cypress/support/step_definitions/chat/Avatar/I_see_an_avatar_for_the_message.js b/cypress/support/step_definitions/chat/Avatar/I_see_an_avatar_for_the_message.js new file mode 100644 index 000000000..53d2bd8a3 --- /dev/null +++ b/cypress/support/step_definitions/chat/Avatar/I_see_an_avatar_for_the_message.js @@ -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) +}) diff --git a/cypress/support/step_definitions/chat/Avatar/I_see_an_avatar_with_initials_in_the_room_list.js b/cypress/support/step_definitions/chat/Avatar/I_see_an_avatar_with_initials_in_the_room_list.js new file mode 100644 index 000000000..eea15dc5e --- /dev/null +++ b/cypress/support/step_definitions/chat/Avatar/I_see_an_avatar_with_initials_in_the_room_list.js @@ -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') +}) diff --git a/cypress/support/step_definitions/chat/FileMessage/I_see_the_room_preview_contains_{string}.js b/cypress/support/step_definitions/chat/FileMessage/I_see_the_room_preview_contains_{string}.js new file mode 100644 index 000000000..d77a44726 --- /dev/null +++ b/cypress/support/step_definitions/chat/FileMessage/I_see_the_room_preview_contains_{string}.js @@ -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') +}) diff --git a/cypress/support/step_definitions/chat/FileMessage/I_upload_the_file_{string}_in_the_chat.js b/cypress/support/step_definitions/chat/FileMessage/I_upload_the_file_{string}_in_the_chat.js new file mode 100644 index 000000000..8102628e8 --- /dev/null +++ b/cypress/support/step_definitions/chat/FileMessage/I_upload_the_file_{string}_in_the_chat.js @@ -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() +}) diff --git a/cypress/support/step_definitions/chat/GroupChat/I_see_a_group_icon_before_{string}_in_the_room_list.js b/cypress/support/step_definitions/chat/GroupChat/I_see_a_group_icon_before_{string}_in_the_room_list.js new file mode 100644 index 000000000..693b26e33 --- /dev/null +++ b/cypress/support/step_definitions/chat/GroupChat/I_see_a_group_icon_before_{string}_in_the_room_list.js @@ -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') +}) diff --git a/cypress/support/step_definitions/chat/RoomFilter/I_clear_the_room_filter.js b/cypress/support/step_definitions/chat/RoomFilter/I_clear_the_room_filter.js new file mode 100644 index 000000000..46677b2c5 --- /dev/null +++ b/cypress/support/step_definitions/chat/RoomFilter/I_clear_the_room_filter.js @@ -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 }) +}) diff --git a/cypress/support/step_definitions/chat/RoomFilter/I_type_{string}_in_the_room_filter.js b/cypress/support/step_definitions/chat/RoomFilter/I_type_{string}_in_the_room_filter.js new file mode 100644 index 000000000..b7cbbf166 --- /dev/null +++ b/cypress/support/step_definitions/chat/RoomFilter/I_type_{string}_in_the_room_filter.js @@ -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) +}) diff --git a/cypress/support/step_definitions/common/I_see_the_message_{string}_in_the_chat.js b/cypress/support/step_definitions/common/I_see_the_message_{string}_in_the_chat.js index 1aa358f45..ac5f702d3 100644 --- a/cypress/support/step_definitions/common/I_see_the_message_{string}_in_the_chat.js +++ b/cypress/support/step_definitions/common/I_see_the_message_{string}_in_the_chat.js @@ -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) }) diff --git a/cypress/support/step_definitions/common/I_see_{string}_in_the_room_list.js b/cypress/support/step_definitions/common/I_see_{string}_in_the_room_list.js new file mode 100644 index 000000000..600412996 --- /dev/null +++ b/cypress/support/step_definitions/common/I_see_{string}_in_the_room_list.js @@ -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') +}) diff --git a/webapp/assets/_new/styles/tokens.scss b/webapp/assets/_new/styles/tokens.scss index 08d18329b..19c0d821d 100644 --- a/webapp/assets/_new/styles/tokens.scss +++ b/webapp/assets/_new/styles/tokens.scss @@ -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; diff --git a/webapp/components/Chat/AddChatRoomByUserSearch.spec.js b/webapp/components/Chat/AddChatRoomByUserSearch.spec.js index 9f0293480..b24017f5b 100644 --- a/webapp/components/Chat/AddChatRoomByUserSearch.spec.js +++ b/webapp/components/Chat/AddChatRoomByUserSearch.spec.js @@ -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() diff --git a/webapp/components/Chat/AddChatRoomByUserSearch.vue b/webapp/components/Chat/AddChatRoomByUserSearch.vue index ed1c33c73..13e5d57c6 100644 --- a/webapp/components/Chat/AddChatRoomByUserSearch.vue +++ b/webapp/components/Chat/AddChatRoomByUserSearch.vue @@ -42,7 +42,14 @@
- {{ option.name }} + + + {{ option.name }} + {{ option.__typename === 'Group' ? `&${option.slug}` : `@${option.slug}` }} @@ -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; diff --git a/webapp/components/Chat/Chat.spec.js b/webapp/components/Chat/Chat.spec.js index 075fc71f5..b43e92e61 100644 --- a/webapp/components/Chat/Chat.spec.js +++ b/webapp/components/Chat/Chat.spec.js @@ -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) + }) + }) }) diff --git a/webapp/components/Chat/Chat.vue b/webapp/components/Chat/Chat.vue index dd20059e6..2833cf662 100644 --- a/webapp/components/Chat/Chat.vue +++ b/webapp/components/Chat/Chat.vue @@ -2,15 +2,15 @@
- + -
-
- {{ getInitialsName(selectedRoom.roomName) }} -
- - +
- + + {{ selectedRoom ? selectedRoom.roomName : '' }} - - {{ selectedRoom ? selectedRoom.roomName : '' }} + +
+
+ +
+
+ + {{ room.roomName }}
-
-
- {{ getInitialsName(room.roomName) }} -
+ + @@ -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 = `${initials}` + 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}` }) }, }, }