Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 78 additions & 28 deletions src/components/MessagesList/MessagesGroup/Message/MessageItem.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
17 changes: 4 additions & 13 deletions src/components/MessagesList/MessagesGroup/Message/MessageItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'))

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -757,20 +757,23 @@ 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
// tile keeps the same size
.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
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import type { ChatMessage } from '../../../../../types/index.ts'

import { computed } from 'vue'
import FilePreview from './FilePreview.vue'
import { getItemTypeFromMessage } from '../../../../../utils/getItemTypeFromMessage.ts'
import { getFilePreviewKeys } from '../../../../../utils/message.ts'

const props = defineProps<{
message: ChatMessage
}>()

const fileKeys = computed(() => getFilePreviewKeys(props.message))

/**
* Get referenceId of a file parameter to look up a local preview (if available).
* Client-only workaround for combined file messages
*
* @param key key of the file parameter ('file', 'file-1', …)
*/
function getReferenceId(key: string): string {
// @ts-expect-error: 'referenceId' does not exist in type RichObjectParameter
return props.message.messageParameters[key].referenceId ?? props.message.referenceId
}
</script>

<template>
<div
class="file-previews-wrapper"
:class="{ 'file-previews-wrapper--combined': fileKeys.length > 1 }">
<FilePreview
v-for="key in fileKeys"
:key="key"
:token="message.token"
:messageId="message.id"
:itemType="getItemTypeFromMessage(message, key)"
:referenceId="getReferenceId(key)"
:file="message.messageParameters[key]" />
</div>
</template>

<style lang="scss" scoped>
.file-previews-wrapper {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--default-grid-baseline);
}

.file-previews-wrapper--combined {
:deep(.file-preview) {
--preview-size: 80px;
--preview-name-height: 24px;
flex-shrink: 0;
width: var(--preview-size);
height: calc(var(--preview-size) + var(--preview-name-height));

.image-container {
width: var(--preview-size) !important;
height: var(--preview-size) !important;
}

.file-preview__image {
width: 100%;
height: 100%;
min-height: unset;
max-height: none;
border-radius: var(--border-radius);

&.mimeicon {
object-fit: contain;
}
}

.name-container {
height: var(--preview-name-height);
font-weight: normal;
font-size: var(--font-size-small);
}
}
}
</style>
Loading
Loading