diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js b/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js index 859bcd7b67d..eef8c145e44 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js +++ b/src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js @@ -18,9 +18,11 @@ 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' +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 +331,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 +361,72 @@ 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) + + // No caption: NcRichText is not rendered at all + 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(messageEl.props('text')).toBe('{file}\n\n' + caption) + 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', () => { diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue b/src/components/MessagesList/MessagesGroup/Message/MessageItem.vue index 2a34266acb5..141998fb415 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,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 { getItemTypeFromMessage } from '../../../../utils/getItemTypeFromMessage.ts' +import { isFilePreviewParameter } from '../../../../utils/message.ts' const LocationCard = defineAsyncComponent(() => import('./MessagePart/LocationCard.vue')) @@ -231,17 +230,9 @@ export default { token: this.message.token, }, } - } 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], - }, - } + } 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) { 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..95e3ce07c4a --- /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..2d8889747f9 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 + hasFilePreviews() { + return getFilePreviewKeys(this.message).length > 0 }, showQuote() { @@ -332,16 +337,15 @@ export default { }, 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 + // File previews are rendered separately, as a block on top of this text (see FilePreviewsWrapper) + 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 } }, @@ -769,6 +773,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 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'). *