From 9645d6b88003edf725f1762e9fa0fa051245f3d0 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 17 Aug 2026 18:09:54 +0200 Subject: [PATCH 1/2] refactor: extract file previews rendering into FilePreviewsWrapper - render all file shares of a message via FilePreviewsWrapper instead of inline placeholders in NcRichText - allow more flexibility in rendering/sorting/modification of combined file messages (client-only) - skip NcRichText for files wthout caption Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Maksim Sukharev --- .../MessagesGroup/Message/MessageItem.spec.js | 85 ++++++++++++------ .../MessagesGroup/Message/MessageItem.vue | 14 +-- .../Message/MessagePart/FilePreview.vue | 36 ++------ .../MessagePart/FilePreviewsWrapper.vue | 87 +++++++++++++++++++ .../Message/MessagePart/MessageBody.vue | 28 +++--- 5 files changed, 166 insertions(+), 84 deletions(-) create mode 100644 src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreviewsWrapper.vue diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js b/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js index 859bcd7b67d..e268115bc4e 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js +++ b/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js @@ -21,6 +21,7 @@ import MessageItem from './MessageItem.vue' import DeckCard from './MessagePart/DeckCard.vue' import DefaultParameter from './MessagePart/DefaultParameter.vue' import FilePreview from './MessagePart/FilePreview.vue' +import FilePreviewsWrapper from './MessagePart/FilePreviewsWrapper.vue' import LocationCard from './MessagePart/LocationCard.vue' import MentionChip from './MessagePart/MentionChip.vue' import router from '../../../../__mocks__/router.js' @@ -329,20 +330,18 @@ describe('MessageItem.vue', () => { mimetype: 'txt/plain', }, } - renderRichObject( - '{file}', - params, - { - actor: { - component: MentionChip, - props: params.actor, - }, - file: { - component: FilePreview, - props: { file: params.file }, - }, - }, - ) + messageProps.message.message = '{file}' + messageProps.message.messageParameters = params + store.dispatch('processMessage', { token: TOKEN, message: messageProps.message }) + const wrapper = mountMessage(messageProps) + + // File previews are rendered as a block, on top of NcRichText + const filePreviewsWrapper = wrapper.findComponent(FilePreviewsWrapper) + expect(filePreviewsWrapper.exists()).toBeTruthy() + expect(filePreviewsWrapper.props('message').messageParameters.file).toStrictEqual(params.file) + + // No caption: NcRichText is not rendered at all + expect(wrapper.findComponent(NcRichText).exists()).toBe(false) }) test('renders single file preview with caption', () => { @@ -361,22 +360,52 @@ describe('MessageItem.vue', () => { mimetype: 'txt/plain', }, } - const messageEl = renderRichObject( - caption, - params, - { - actor: { - component: MentionChip, - props: params.actor, - }, - file: { - component: FilePreview, - props: { file: params.file }, - }, + messageProps.message.message = caption + messageProps.message.messageParameters = params + store.dispatch('processMessage', { token: TOKEN, message: messageProps.message }) + const wrapper = mountMessage(messageProps) + + // Caption is rendered as-is, the file placeholder is not part of it anymore + // (no 'file' argument either: previews are rendered by FilePreviewsWrapper, not NcRichText) + const messageEl = wrapper.findComponent(NcRichText) + expect(messageEl.props('text')).toBe(caption) + expect(Object.keys(messageEl.props('arguments'))).toMatchObject(['actor']) + + // File previews are still rendered as a block, on top of the caption + const filePreviewsWrapper = wrapper.findComponent(FilePreviewsWrapper) + expect(filePreviewsWrapper.exists()).toBeTruthy() + expect(filePreviewsWrapper.props('message').messageParameters.file).toStrictEqual(params.file) + }) + + test('renders combined file previews as a single block', () => { + const params = { + 'file-1': { + id: '123', + path: 'Talk/first.txt', + name: 'first.txt', + type: 'file', + mimetype: 'txt/plain', }, - ) + 'file-2': { + id: '456', + path: 'Talk/second.txt', + name: 'second.txt', + type: 'file', + mimetype: 'txt/plain', + }, + } + messageProps.message.message = '{file-1} {file-2}' + messageProps.message.messageParameters = params + store.dispatch('processMessage', { token: TOKEN, message: messageProps.message }) + const wrapper = mountMessage(messageProps) + + // Both files are rendered together, as a single FilePreviewsWrapper + const filePreviewsWrapper = wrapper.findComponent(FilePreviewsWrapper) + expect(filePreviewsWrapper.exists()).toBeTruthy() + expect(filePreviewsWrapper.findAllComponents(FilePreview)).toHaveLength(2) - expect(messageEl.props('text')).toBe('{file}\n\n' + caption) + // No caption: NcRichText is not rendered at all + expect(wrapper.findComponent(NcRichText).exists()).toBe(false) }) test('renders deck cards', () => { diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue b/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue index 2a34266acb5..3083d09e9b5 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue @@ -103,7 +103,6 @@ import ScheduledMessageActions from './MessageButtonsBar/ScheduledMessageActions import ContactCard from './MessagePart/ContactCard.vue' import DeckCard from './MessagePart/DeckCard.vue' import DefaultParameter from './MessagePart/DefaultParameter.vue' -import FilePreview from './MessagePart/FilePreview.vue' import MentionChip from './MessagePart/MentionChip.vue' import MessageBody from './MessagePart/MessageBody.vue' import PollCard from './MessagePart/PollCard.vue' @@ -114,7 +113,6 @@ import { hasTalkFeature } from '../../../../services/CapabilitiesManager.ts' import { EventBus } from '../../../../services/EventBus.ts' import { useActorStore } from '../../../../stores/actor.ts' import { useChatExtrasStore } from '../../../../stores/chatExtras.ts' -import { getItemTypeFromMessage } from '../../../../utils/getItemTypeFromMessage.ts' const LocationCard = defineAsyncComponent(() => import('./MessagePart/LocationCard.vue')) @@ -232,16 +230,8 @@ export default { }, } } else if (type === 'file' && mimetype !== 'text/vcard') { - richParameters[p] = { - component: FilePreview, - props: { - token: this.message.token, - messageId: this.message.id, - itemType: getItemTypeFromMessage(this.message, p), - referenceId: this.message.messageParameters[p].referenceId ?? this.message.referenceId, - file: this.message.messageParameters[p], - }, - } + // File previews are rendered by FilePreviewsWrapper, skip from richParameters + return } else if (type === SHARED_ITEM.OBJECT_TYPE.DECK_CARD) { richParameters[p] = { component: DeckCard, diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue index cd88d296205..0ef6a60f6fe 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue @@ -757,13 +757,15 @@ export default { } } - // Both the upload editor and a message with combined file shares show a grid - // of tiles of the same size. --preview-size and --preview-name-height define sizes. + // The upload editor shows a grid of tiles of the same size. + // --preview-size and --preview-name-height define sizes. // The file name is not shown for every file, but its space is always reserved. - &--upload-editor, - .message-main--combined-files & { + // (FilePreviewsWrapper applies the same sizing to combined file shares in messages) + &--upload-editor { width: var(--preview-size, 80px); height: calc(var(--preview-size, 80px) + var(--preview-name-height, 24px)); + padding: var(--preview-padding, 8px); + margin: var(--default-grid-baseline); // A file without a preview gets an inline 128px size on the container // and a min-height on the icon. Both are overridden, so that every @@ -771,6 +773,7 @@ export default { .image-container { width: var(--preview-size, 80px) !important; height: var(--preview-size, 80px) !important; + outline: 1px solid var(--color-border); } // The size class of the image varies with the preview type, so it is @@ -790,15 +793,6 @@ export default { } } - &--upload-editor { - padding: var(--preview-padding, 8px); - margin: var(--default-grid-baseline); - - .image-container { - outline: 1px solid var(--color-border); - } - } - &--row-layout { display: flex; align-items: center; @@ -816,22 +810,6 @@ export default { } } - .message-main--combined-files & { - // Same thumbnail size as in the upload editor - --preview-size: 80px; - --preview-name-height: 24px; - // Align inline elements to the top, so looks even next to files with filename - vertical-align: top; - - .file-preview__image { - border-radius: var(--border-radius); - - &.mimeicon { - object-fit: contain; - } - } - } - &--shared-items-grid { aspect-ratio: 1; line-height: 0; diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreviewsWrapper.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreviewsWrapper.vue new file mode 100644 index 00000000000..59a0bf517d0 --- /dev/null +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreviewsWrapper.vue @@ -0,0 +1,87 @@ + + + + + + + diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue index 99f8c0eb880..490e97fcbf2 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue @@ -11,7 +11,6 @@ 'message-main--sided': isSplitViewEnabled && !isSystemMessage, 'message-main--compressed': isSplitViewEnabled && isShortSimpleMessage, 'message-main--compressed-system': isSplitViewEnabled && isSystemMessage, - 'message-main--combined-files': hasCombinedFiles, }">

+ + + 1 - }, - showQuote() { return !!this.message.parent && this.message.parent.id !== this.threadId }, renderedMessage() { - if (this.isFileShare) { - if (this.isFileShareWithoutCaption) { - return this.message.message - } - // Add a new line after file to split content into different paragraphs - const filePlaceholdersString = getFileKeys(this.message).map((key) => `{${key}}`).join(' ') - return filePlaceholdersString + '\n\n' + this.message.message - } else if (this.isLocationMessageWithName) { + // File previews are rendered separately, as a block on top of this text (see FilePreviewsWrapper) + if (this.isLocationMessageWithName) { return this.message.message + '\n\n' + this.message.messageParameters.object.name - } { + } else { return this.message.message } }, @@ -769,6 +763,10 @@ export default { &.markdown-message { position: relative; + :deep(.file-previews-wrapper:has(+ .rich-text--wrapper)) { + margin-block-end: 1em; + } + :deep(.rich-text--wrapper) { // NcRichText is used with dir="auto", so internal text direction may vary // But we want to keep the alignment consistent with the rest of the UI From e5e1aeff317b40fc697d3fb06f5d7906643b0264 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 17 Aug 2026 18:12:08 +0200 Subject: [PATCH 2/2] fix(chat): exclude contact cards from FilePreviewsWrapper - contact card shares use the same 'file' key as regular files, but rendered with ContactCard.vue - they are not excluded from richParameters and rendered via NcRichText Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Maksim Sukharev --- .../MessagesGroup/Message/MessageItem.spec.js | 21 +++++++++++++++++++ .../MessagesGroup/Message/MessageItem.vue | 3 ++- .../MessagePart/FilePreviewsWrapper.vue | 4 ++-- .../Message/MessagePart/MessageBody.vue | 16 +++++++++++--- src/utils/message.ts | 21 +++++++++++++++++++ 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js b/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js index e268115bc4e..eef8c145e44 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js +++ b/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js @@ -18,6 +18,7 @@ import MessageQuote from '../../../MessageQuote.vue' import CallButton from '../../../TopBar/CallButton.vue' import MessageButtonsBar from './MessageButtonsBar/MessageButtonsBar.vue' import MessageItem from './MessageItem.vue' +import ContactCard from './MessagePart/ContactCard.vue' import DeckCard from './MessagePart/DeckCard.vue' import DefaultParameter from './MessagePart/DefaultParameter.vue' import FilePreview from './MessagePart/FilePreview.vue' @@ -408,6 +409,26 @@ describe('MessageItem.vue', () => { expect(wrapper.findComponent(NcRichText).exists()).toBe(false) }) + test('renders contact card (text/vcard) via NcRichText', () => { + const params = { + file: { + id: '123', + type: 'file', + mimetype: 'text/vcard', + name: 'John Doe.vcf', + link: 'https://example.com/John%20Doe.vcf', + }, + } + messageProps.message.message = '{file}' + messageProps.message.messageParameters = params + store.dispatch('processMessage', { token: TOKEN, message: messageProps.message }) + const wrapper = mountMessage(messageProps) + + expect(wrapper.findComponent(NcRichText).exists()).toBe(true) + expect(wrapper.findComponent(ContactCard).exists()).toBe(true) + expect(wrapper.findComponent(FilePreviewsWrapper).exists()).toBe(false) + }) + test('renders deck cards', () => { const params = { actor: { diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue b/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue index 3083d09e9b5..141998fb415 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue @@ -113,6 +113,7 @@ import { hasTalkFeature } from '../../../../services/CapabilitiesManager.ts' import { EventBus } from '../../../../services/EventBus.ts' import { useActorStore } from '../../../../stores/actor.ts' import { useChatExtrasStore } from '../../../../stores/chatExtras.ts' +import { isFilePreviewParameter } from '../../../../utils/message.ts' const LocationCard = defineAsyncComponent(() => import('./MessagePart/LocationCard.vue')) @@ -229,7 +230,7 @@ export default { token: this.message.token, }, } - } else if (type === 'file' && mimetype !== 'text/vcard') { + } else if (isFilePreviewParameter(p, this.message.messageParameters[p])) { // File previews are rendered by FilePreviewsWrapper, skip from richParameters return } else if (type === SHARED_ITEM.OBJECT_TYPE.DECK_CARD) { diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreviewsWrapper.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreviewsWrapper.vue index 59a0bf517d0..95e3ce07c4a 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreviewsWrapper.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreviewsWrapper.vue @@ -9,13 +9,13 @@ import type { ChatMessage } from '../../../../../types/index.ts' import { computed } from 'vue' import FilePreview from './FilePreview.vue' import { getItemTypeFromMessage } from '../../../../../utils/getItemTypeFromMessage.ts' -import { getFileKeys } from '../../../../../utils/message.ts' +import { getFilePreviewKeys } from '../../../../../utils/message.ts' const props = defineProps<{ message: ChatMessage }>() -const fileKeys = computed(() => getFileKeys(props.message)) +const fileKeys = computed(() => getFilePreviewKeys(props.message)) /** * Get referenceId of a file parameter to look up a local preview (if available). diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue index 490e97fcbf2..2d8889747f9 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue @@ -66,11 +66,11 @@ - + 0 + }, + showQuote() { return !!this.message.parent && this.message.parent.id !== this.threadId }, renderedMessage() { // File previews are rendered separately, as a block on top of this text (see FilePreviewsWrapper) - if (this.isLocationMessageWithName) { + if (this.isFileShare && !this.isFileShareWithoutCaption) { + // Contact cards with mimetype 'text/vcard' are rendered here. + // In case of caption present, placeholder should be put before it. + const vcardKey = getFileKeys(this.message).find((key) => !isFilePreviewParameter(key, this.message.messageParameters[key])) + return vcardKey ? `{${vcardKey}}\n\n${this.message.message}` : this.message.message + } else if (this.isLocationMessageWithName) { return this.message.message + '\n\n' + this.message.messageParameters.object.name } else { return this.message.message diff --git a/src/utils/message.ts b/src/utils/message.ts index 0a2cf6022e1..ca2db3dd5f8 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -197,6 +197,27 @@ export function getFileKeys(message: Pick) { return Object.keys(message.messageParameters ?? {}).filter((key) => FILE_KEY_REGEX.test(key)) } +/** + * Checks whether a message parameter should be rendered as a preview in a FilePreviewsWrapper + * (Contact cards with mimetype 'text/vcard' are rendered separately) + * + * @param key key of the message parameter ('file', 'file-1', …) + * @param parameter the message parameter itself + * @param parameter.mimetype the parameter's mimetype, if any + */ +export function isFilePreviewParameter(key: string, parameter: { mimetype?: string }): boolean { + return FILE_KEY_REGEX.test(key) && parameter.mimetype !== 'text/vcard' +} + +/** + * Returns keys of files shared with the message to be rendered with a FilePreviewsWrapper + * + * @param message Chat message (or an object with messageParameters) + */ +export function getFilePreviewKeys(message: Pick) { + return getFileKeys(message).filter((key) => isFilePreviewParameter(key, message.messageParameters[key])) +} + /** * Returns whether the given message shares a file (has a rich object parameter with a key starting with 'file'). *