diff options
author | Maksim Sukharev <antreesy.web@gmail.com> | 2023-10-23 15:11:15 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-10-23 15:11:15 +0200 |
commit | 701763ecb737834149af784f6303dc6ffbdca04a (patch) | |
tree | 7dceb863845ed189e5f92ea7e6b4a3e0a5517806 | |
parent | ab03ae1f3f05f6c68e99a1db863fe44d33fa9635 (diff) | |
parent | 892c22b7e29ebf0248f143adcc07eb3ea71bf8ce (diff) |
Merge pull request #10730 from nextcloud/feat/5354/captions-frontend
feat(NewMessageUploadEditor) - caption to file share
-rw-r--r-- | src/components/MessagesList/MessagesGroup/Message/Message.spec.js | 33 | ||||
-rw-r--r-- | src/components/MessagesList/MessagesGroup/Message/Message.vue | 27 | ||||
-rw-r--r-- | src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue | 16 | ||||
-rw-r--r-- | src/components/NewMessage/NewMessage.vue | 31 | ||||
-rw-r--r-- | src/components/NewMessage/NewMessageUploadEditor.vue | 67 | ||||
-rw-r--r-- | src/store/fileUploadStore.js | 127 | ||||
-rw-r--r-- | src/store/fileUploadStore.spec.js | 94 |
7 files changed, 254 insertions, 141 deletions
diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.spec.js b/src/components/MessagesList/MessagesGroup/Message/Message.spec.js index 232c7aeda..46bf01aa5 100644 --- a/src/components/MessagesList/MessagesGroup/Message/Message.spec.js +++ b/src/components/MessagesList/MessagesGroup/Message/Message.spec.js @@ -325,6 +325,7 @@ describe('Message.vue', () => { const messageEl = wrapper.findComponent({ name: 'NcRichText' }) // note: indices as object keys are on purpose expect(messageEl.props('arguments')).toMatchObject(expectedRichParameters) + return messageEl } test('renders mentions', () => { @@ -364,7 +365,7 @@ describe('Message.vue', () => { ) }) - test('renders file previews', () => { + test('renders single file preview', () => { const params = { actor: { id: 'alice', @@ -391,6 +392,36 @@ describe('Message.vue', () => { ) }) + test('renders single file preview with caption', () => { + const caption = 'text caption' + const params = { + actor: { + id: 'alice', + name: 'Alice', + type: 'user', + }, + file: { + path: 'some/path', + type: 'file', + }, + } + const messageEl = renderRichObject( + caption, + params, { + actor: { + component: Mention, + props: params.actor, + }, + file: { + component: FilePreview, + props: params.file, + }, + } + ) + + expect(messageEl.props('text')).toBe('{file}' + '\n\n' + caption) + }) + test('renders deck cards', () => { const params = { actor: { diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.vue b/src/components/MessagesList/MessagesGroup/Message/Message.vue index 1032bcab5..c282e823a 100644 --- a/src/components/MessagesList/MessagesGroup/Message/Message.vue +++ b/src/components/MessagesList/MessagesGroup/Message/Message.vue @@ -47,11 +47,11 @@ the main body of the message as well as a quote. class="message-body__main__text"> <Quote v-if="parent" v-bind="parent" /> <div class="single-emoji"> - {{ message }} + {{ renderedMessage }} </div> </div> <div v-else-if="showJoinCallButton" class="message-body__main__text call-started"> - <NcRichText :text="message" + <NcRichText :text="renderedMessage" :arguments="richParameters" autolink dir="auto" @@ -59,7 +59,7 @@ the main body of the message as well as a quote. <CallButton /> </div> <div v-else-if="showResultsButton || isSystemMessage" class="message-body__main__text system-message"> - <NcRichText :text="message" + <NcRichText :text="renderedMessage" :arguments="richParameters" autolink dir="auto" @@ -72,7 +72,7 @@ the main body of the message as well as a quote. show-as-button /> </div> <div v-else-if="isDeletedMessage" class="message-body__main__text deleted-message"> - <NcRichText :text="message" + <NcRichText :text="renderedMessage" :arguments="richParameters" autolink dir="auto" @@ -83,7 +83,7 @@ the main body of the message as well as a quote. @mouseover="handleMarkdownMouseOver" @mouseleave="handleMarkdownMouseLeave"> <Quote v-if="parent" v-bind="parent" /> - <NcRichText :text="message" + <NcRichText :text="renderedMessage" :arguments="richParameters" autolink dir="auto" @@ -457,6 +457,11 @@ export default { type: Array, default: () => { return [] }, }, + + referenceId: { + type: String, + default: '', + }, }, emits: ['toggle-combined-system-message'], @@ -502,6 +507,15 @@ export default { return !this.isLastMessage && this.id === this.$store.getters.getVisualLastReadMessageId(this.token) }, + renderedMessage() { + if (this.messageParameters?.file && this.message !== '{file}') { + // Add a new line after file to split content into different paragraphs + return '{file}' + '\n\n' + this.message + } else { + return this.message + } + }, + messageObject() { return this.$store.getters.message(this.token, this.id) }, @@ -568,7 +582,7 @@ export default { let match let emojiStrings = '' let emojiCount = 0 - const trimmedMessage = this.message.trim() + const trimmedMessage = this.renderedMessage.trim() // eslint-disable-next-line no-cond-assign while (match = regex.exec(trimmedMessage)) { @@ -600,6 +614,7 @@ export default { props: Object.assign({ token: this.token, itemType, + referenceId: this.referenceId, }, this.messageParameters[p]), } } else if (type === 'deck-card') { diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue index b2e76a1fa..ced1222d7 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue @@ -32,7 +32,7 @@ 'file-preview--row-layout': rowLayout }" @click.exact="handleClick" @keydown.enter="handleClick"> - <div v-if="!isLoading" + <div v-if="!isLoading || fallbackLocalUrl" class="image-container" :class="{'playable': isPlayable}"> <span v-if="isPlayable && !smallPreview" class="play-video-button"> @@ -122,6 +122,13 @@ export default { required: true, }, /** + * Reference id from the message + */ + referenceId: { + type: String, + default: '', + }, + /** * File name */ name: { @@ -283,6 +290,10 @@ export default { return this.name }, + fallbackLocalUrl() { + return this.$store.getters.getLocalUrl(this.referenceId) + }, + previewTooltip() { if (this.shouldShowFileDetail) { // no tooltip as the file name is already visible directly @@ -363,6 +374,9 @@ export default { if (this.previewType === PREVIEW_TYPE.TEMPORARY) { return this.localUrl } + if (this.fallbackLocalUrl) { + return this.fallbackLocalUrl + } if (this.previewType === PREVIEW_TYPE.MIME_ICON || this.rowLayout) { return OC.MimeType.getIconUrl(this.mimetype) } diff --git a/src/components/NewMessage/NewMessage.vue b/src/components/NewMessage/NewMessage.vue index dd9ef6753..ab4fd1639 100644 --- a/src/components/NewMessage/NewMessage.vue +++ b/src/components/NewMessage/NewMessage.vue @@ -109,7 +109,7 @@ <!-- Send buttons --> <template v-else> - <NcActions v-if="!broadcast" + <NcActions v-if="!broadcast && !upload" :container="container" :force-menu="true"> <!-- Silent send --> @@ -241,6 +241,14 @@ export default { }, /** + * Upload files caption. + */ + upload: { + type: Boolean, + default: false, + }, + + /** * Show an indicator if someone is currently typing a message. */ hasTypingIndicator: { @@ -359,11 +367,11 @@ export default { }, showAttachmentsMenu() { - return this.canShareFiles && !this.broadcast + return this.canShareFiles && !this.broadcast && !this.upload }, showAudioRecorder() { - return !this.hasText && this.canUploadFiles && !this.broadcast + return !this.hasText && this.canUploadFiles && !this.broadcast && !this.upload }, showTypingStatus() { return this.hasTypingIndicator && this.supportTypingStatus @@ -446,8 +454,15 @@ export default { }, handleUploadStart() { - // refocus on upload start as the user might want to type again while the upload is running - this.focusInput() + if (this.upload) { + return + } + this.$nextTick(() => { + // reset main input in chat view after upload file with caption + this.text = this.$store.getters.currentMessageInput(this.token) + // refocus on upload start as the user might want to type again while the upload is running + this.focusInput() + }) }, /** @@ -466,6 +481,12 @@ export default { } } + if (this.upload) { + this.$emit('sent', this.text) + this.$store.dispatch('setCurrentMessageInput', { token: this.token, text: '' }) + return + } + if (this.hasText) { // FIXME upstream: https://github.com/nextcloud-libraries/nextcloud-vue/issues/4492 const temp = document.createElement('textarea') diff --git a/src/components/NewMessage/NewMessageUploadEditor.vue b/src/components/NewMessage/NewMessageUploadEditor.vue index 2837cb391..b0d687bba 100644 --- a/src/components/NewMessage/NewMessageUploadEditor.vue +++ b/src/components/NewMessage/NewMessageUploadEditor.vue @@ -21,6 +21,7 @@ <template> <NcModal v-if="showModal" + ref="modal" :size="isVoiceMessage ? 'small' : 'normal'" class="upload-editor" :container="container" @@ -39,9 +40,9 @@ tag="div" group> <template v-for="file in files"> - <FilePreview :key="file.temporaryMessage.id" + <FilePreview :key="file[1].temporaryMessage.id" :token="token" - v-bind="file.temporaryMessage.messageParameters.file" + v-bind="file[1].temporaryMessage.messageParameters.file" :is-upload-editor="true" @remove-file="handleRemoveFileFromSelection" /> </template> @@ -62,14 +63,15 @@ <AudioPlayer :name="voiceMessageName" :local-url="voiceMessageLocalURL" /> </template> - <div class="upload-editor__actions"> - <NcButton type="tertiary" @click="handleDismiss"> - {{ t('spreed', 'Dismiss') }} - </NcButton> - <NcButton ref="submitButton" type="primary" @click="handleUpload"> - {{ t('spreed', 'Send') }} - </NcButton> - </div> + <NewMessage v-if="modalContainerId" + ref="newMessage" + role="region" + upload + :token="token" + :container="modalContainerId" + :aria-label="t('spreed', 'Post message')" + @sent="handleUpload" + @failure="handleDismiss" /> </div> </NcModal> </template> @@ -83,6 +85,7 @@ import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' import AudioPlayer from '../MessagesList/MessagesGroup/Message/MessagePart/AudioPlayer.vue' import FilePreview from '../MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue' +import NewMessage from './NewMessage.vue' import TransitionWrapper from '../TransitionWrapper.vue' export default { @@ -94,9 +97,16 @@ export default { Plus, AudioPlayer, NcButton, + NewMessage, TransitionWrapper, }, + data() { + return { + modalContainerId: null, + } + }, + computed: { token() { return this.$store.getters.getToken() @@ -107,10 +117,7 @@ export default { }, files() { - if (this.currentUploadId) { - return this.$store.getters.getInitialisedUploads(this.currentUploadId) - } - return [] + return this.$store.getters.getInitialisedUploads(this.currentUploadId) }, showModal() { @@ -126,11 +133,10 @@ export default { }, firstFile() { - return this.files[Object.keys(this.files)[0]] + return this.files?.at(0)?.at(1) }, - // Hide the plus button in case this editor is used while sending a voice - // message + // Hide the plus button in case this editor is used while sending a voice message isVoiceMessage() { if (!this.firstFile) { return false @@ -154,17 +160,21 @@ export default { }, watch: { - showModal(show) { + async showModal(show) { if (show) { - this.focus() + await this.getContainerId() + this.$nextTick(() => { + this.$refs.newMessage?.focusInput() + }) } }, }, methods: { - focus() { + async getContainerId() { this.$nextTick(() => { - this.$refs.submitButton.$el.focus() + // Postpone render of NewMessage until modal container is mounted + this.modalContainerId = `#modal-description-${this.$refs.modal.randId}` }) }, @@ -172,8 +182,8 @@ export default { this.$store.dispatch('discardUpload', this.currentUploadId) }, - handleUpload() { - this.$store.dispatch('uploadFiles', this.currentUploadId) + handleUpload(caption) { + this.$store.dispatch('uploadFiles', { uploadId: this.currentUploadId, caption }) }, /** * Clicks the hidden file input when clicking the correspondent NcActionButton, @@ -204,21 +214,11 @@ export default { padding: 16px; &__previews { - overflow-x: hidden !important; display: flex; position: relative; overflow: auto; flex-wrap: wrap; } - &__actions { - display: flex; - justify-content: space-between; - margin-top: 16px; - margin-bottom: 4px; - button { - margin: 0 4px 0 4px; - } - } } .add-more { @@ -230,5 +230,4 @@ export default { margin: auto; } } - </style> diff --git a/src/store/fileUploadStore.js b/src/store/fileUploadStore.js index fee6ec559..a167927bf 100644 --- a/src/store/fileUploadStore.js +++ b/src/store/fileUploadStore.js @@ -38,46 +38,33 @@ import { findUniquePath, getFileExtension } from '../utils/fileUpload.js' const state = { attachmentFolder: loadState('spreed', 'attachment_folder', ''), attachmentFolderFreeSpace: loadState('spreed', 'attachment_folder_free_space', 0), - uploads: { - }, + uploads: {}, currentUploadId: undefined, - + localUrls: {}, fileTemplatesInitialised: false, fileTemplates: [], } const getters = { - getInitialisedUploads: (state) => (uploadId) => { + getUploadsArray: (state) => (uploadId) => { if (state.uploads[uploadId]) { - const initialisedUploads = {} - for (const index in state.uploads[uploadId].files) { - const currentFile = state.uploads[uploadId].files[index] - if (currentFile.status === 'initialised') { - initialisedUploads[index] = (currentFile) - } - } - return initialisedUploads + return Object.entries(state.uploads[uploadId].files) } else { - return {} + return [] } }, + getInitialisedUploads: (state, getters) => (uploadId) => { + return getters.getUploadsArray(uploadId) + .filter(([_index, uploadedFile]) => uploadedFile.status === 'initialised') + }, + // Returns all the files that have been successfully uploaded provided an // upload id - getShareableFiles: (state) => (uploadId) => { - if (state.uploads[uploadId]) { - const shareableFiles = {} - for (const index in state.uploads[uploadId].files) { - const currentFile = state.uploads[uploadId].files[index] - if (currentFile.status === 'successUpload') { - shareableFiles[index] = (currentFile) - } - } - return shareableFiles - } else { - return {} - } + getShareableFiles: (state, getters) => (uploadId) => { + return getters.getUploadsArray(uploadId) + .filter(([_index, uploadedFile]) => uploadedFile.status === 'successUpload') }, // gets the current attachment folder @@ -90,6 +77,11 @@ const getters = { return state.attachmentFolderFreeSpace }, + // returns the local Url of uploaded image + getLocalUrl: (state) => (referenceId) => { + return state.localUrls[referenceId] + }, + uploadProgress: (state) => (uploadId, index) => { if (state.uploads[uploadId].files[index]) { return state.uploads[uploadId].files[index].uploadedSize / state.uploads[uploadId].files[index].totalSize * 100 @@ -114,7 +106,7 @@ const getters = { const mutations = { // Adds a "file to be shared to the store" - addFileToBeUploaded(state, { file, temporaryMessage }) { + addFileToBeUploaded(state, { file, temporaryMessage, localUrl }) { const uploadId = temporaryMessage.messageParameters.file.uploadId const token = temporaryMessage.messageParameters.file.token const index = temporaryMessage.messageParameters.file.index @@ -132,6 +124,7 @@ const mutations = { uploadedSize: 0, temporaryMessage, }) + Vue.set(state.localUrls, temporaryMessage.referenceId, localUrl) }, // Marks a given file as failed upload @@ -254,7 +247,7 @@ const actions = { text: '{file}', token, uploadId, index, file, localUrl, isVoiceMessage, }) console.debug('temporarymessage: ', temporaryMessage, 'uploadId', uploadId) - commit('addFileToBeUploaded', { file, temporaryMessage }) + commit('addFileToBeUploaded', { file, temporaryMessage, localUrl }) } }, @@ -282,31 +275,38 @@ const actions = { * @param {Function} context.dispatch the contexts dispatch function. * @param {object} context.getters the contexts getters object. * @param {object} context.state the contexts state object. - * @param {string} uploadId The unique uploadId + * @param {object} data the wrapping object + * @param {string} data.uploadId The unique uploadId + * @param {string} [data.caption] The text caption to the media */ - async uploadFiles({ commit, dispatch, state, getters }, uploadId) { + async uploadFiles({ commit, dispatch, state, getters }, { uploadId, caption }) { if (state.currentUploadId === uploadId) { commit('setCurrentUploadId', undefined) } EventBus.$emit('upload-start') - // Tag the previously indexed files and add the temporary messages to the - // messages list - for (const index in state.uploads[uploadId].files) { + // Tag previously indexed files and add temporary messages to the MessagesList + // If caption is provided, attach to the last temporary message + const lastIndex = getters.getUploadsArray(uploadId).at(-1).at(0) + for (const [index, uploadedFile] of getters.getUploadsArray(uploadId)) { // mark all files as uploading commit('markFileAsUploading', { uploadId, index }) // Store the previously created temporary message - const temporaryMessage = state.uploads[uploadId].files[index].temporaryMessage + const temporaryMessage = { + ...uploadedFile.temporaryMessage, + message: index === lastIndex ? caption : '{file}', + } // Add temporary messages (files) to the messages list dispatch('addTemporaryMessage', temporaryMessage) // Scroll the message list - EventBus.$emit('scroll-chat-to-bottom') + EventBus.$emit('scroll-chat-to-bottom', { force: true }) } + // Iterate again and perform the uploads - for (const index in state.uploads[uploadId].files) { + await Promise.allSettled(getters.getUploadsArray(uploadId).map(async ([index, uploadedFile]) => { // currentFile to be uploaded - const currentFile = state.uploads[uploadId].files[index].file + const currentFile = uploadedFile.file // userRoot path const userRoot = '/files/' + getters.getUserId() const fileName = (currentFile.newName || currentFile.name) @@ -343,41 +343,34 @@ const actions = { showError(t('spreed', 'Error while uploading file "{fileName}"', { fileName })) } - const temporaryMessage = state.uploads[uploadId].files[index].temporaryMessage // Mark the upload as failed in the store commit('markFileAsFailedUpload', { uploadId, index }) - dispatch('markTemporaryMessageAsFailed', { - message: temporaryMessage, - reason, - }) + dispatch('markTemporaryMessageAsFailed', { message: uploadedFile.temporaryMessage, reason }) } - - // Get the files that have successfully been uploaded from the store - const shareableFiles = getters.getShareableFiles(uploadId) - // Share each of those files to the conversation - for (const index in shareableFiles) { - const path = shareableFiles[index].sharePath - const temporaryMessage = shareableFiles[index].temporaryMessage - const metadata = JSON.stringify({ messageType: temporaryMessage.messageType }) - try { - const token = temporaryMessage.token - dispatch('markFileAsSharing', { uploadId, index }) - await shareFile(path, token, temporaryMessage.referenceId, metadata) - dispatch('markFileAsShared', { uploadId, index }) - } catch (error) { - if (error?.response?.status === 403) { - showError(t('spreed', 'You are not allowed to share files')) - } else { - showError(t('spreed', 'An error happened when trying to share your file')) - } - dispatch('markTemporaryMessageAsFailed', { - message: temporaryMessage, - reason: 'failed-share', - }) - console.error('An error happened when trying to share your file: ', error) + })) + + // Share the files, that have successfully been uploaded from the store, to the conversation + await Promise.all(getters.getShareableFiles(uploadId).map(async ([index, shareableFile]) => { + const path = shareableFile.sharePath + const temporaryMessage = shareableFile.temporaryMessage + const metadata = (caption && index === lastIndex) + ? JSON.stringify({ messageType: temporaryMessage.messageType, caption }) + : JSON.stringify({ messageType: temporaryMessage.messageType }) + try { + const token = temporaryMessage.token + dispatch('markFileAsSharing', { uploadId, index }) + await shareFile(path, token, temporaryMessage.referenceId, metadata) + dispatch('markFileAsShared', { uploadId, index }) + } catch (error) { + if (error?.response?.status === 403) { + showError(t('spreed', 'You are not allowed to share files')) + } else { + showError(t('spreed', 'An error happened when trying to share your file')) } + dispatch('markTemporaryMessageAsFailed', { message: temporaryMessage, reason: 'failed-share' }) + console.error('An error happened when trying to share your file: ', error) } - } + })) EventBus.$emit('upload-finished') }, /** diff --git a/src/store/fileUploadStore.spec.js b/src/store/fileUploadStore.spec.js index c35c99472..cf1762f6e 100644 --- a/src/store/fileUploadStore.spec.js +++ b/src/store/fileUploadStore.spec.js @@ -111,6 +111,8 @@ describe('fileUploadStore', () => { lastModified: Date.UTC(2021, 3, 25, 15, 30, 0), }, ] + const localUrls = ['local-url:pngimage.png', 'local-url:jpgimage.jpg', 'icon-url:text/plain'] + await store.dispatch('initialiseUpload', { uploadId: 'upload-id1', token: 'XXTOKENXX', @@ -118,22 +120,58 @@ describe('fileUploadStore', () => { }) const uploads = store.getters.getInitialisedUploads('upload-id1') - expect(Object.keys(uploads).length).toBe(3) - - for (let i = 0; i < files.length; i++) { - expect(mockedActions.createTemporaryMessage.mock.calls[i][1].text).toBe('{file}') - expect(mockedActions.createTemporaryMessage.mock.calls[i][1].uploadId).toBe('upload-id1') - expect(mockedActions.createTemporaryMessage.mock.calls[i][1].index).toBeDefined() - expect(mockedActions.createTemporaryMessage.mock.calls[i][1].file).toBe(files[i]) - expect(mockedActions.createTemporaryMessage.mock.calls[i][1].token).toBe('XXTOKENXX') + expect(uploads).toHaveLength(files.length) + + for (const index in files) { + expect(mockedActions.createTemporaryMessage.mock.calls[index][1]).toMatchObject({ + text: '{file}', + token: 'XXTOKENXX', + uploadId: 'upload-id1', + index: expect.anything(), + file: files[index], + localUrl: localUrls[index], + }) + } + }) + + test('performs upload and sharing of single file', async () => { + const file = { + name: 'pngimage.png', + type: 'image/png', + size: 123, + lastModified: Date.UTC(2021, 3, 27, 15, 30, 0), } + const fileBuffer = await new Blob([file]).arrayBuffer() + + await store.dispatch('initialiseUpload', { + uploadId: 'upload-id1', + token: 'XXTOKENXX', + files: [file], + }) + + expect(store.getters.currentUploadId).toBe('upload-id1') - expect(mockedActions.createTemporaryMessage.mock.calls[0][1].localUrl).toBe('local-url:pngimage.png') - expect(mockedActions.createTemporaryMessage.mock.calls[1][1].localUrl).toBe('local-url:jpgimage.jpg') - expect(mockedActions.createTemporaryMessage.mock.calls[2][1].localUrl).toBe('icon-url:text/plain') + const uniqueFileName = '/Talk/' + file.name + 'uniq' + findUniquePath.mockResolvedValueOnce(uniqueFileName) + client.putFileContents.mockResolvedValue() + shareFile.mockResolvedValue() + + await store.dispatch('uploadFiles', { uploadId: 'upload-id1', caption: 'text-caption' }) + + expect(findUniquePath).toHaveBeenCalledTimes(1) + expect(findUniquePath).toHaveBeenCalledWith(client, '/files/current-user', '/Talk/' + file.name) + + expect(client.putFileContents).toHaveBeenCalledTimes(1) |