Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/mail.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMikhail Sazanov <m@sazanof.ru>2022-06-17 14:57:45 +0300
committerMikhail Sazanov <m@sazanof.ru>2022-06-17 14:57:45 +0300
commit258ea67d0cfe754e5e8b91e69343ea9ec11e1156 (patch)
tree7866a57dd49eb6b07d549076b7fbc5ec798e76c4
parent7ad8b9626d1b70ab3c68fa601439d2f6a1b57964 (diff)
Improve the way attachment look
Signed-off-by: Mikhail Sazanov <m@sazanof.ru>
-rw-r--r--src/components/ComposerAttachment.vue212
-rw-r--r--src/components/ComposerAttachments.vue314
-rw-r--r--src/service/AttachmentService.js5
-rw-r--r--src/service/FileService.js19
4 files changed, 478 insertions, 72 deletions
diff --git a/src/components/ComposerAttachment.vue b/src/components/ComposerAttachment.vue
new file mode 100644
index 000000000..486bf84d9
--- /dev/null
+++ b/src/components/ComposerAttachment.vue
@@ -0,0 +1,212 @@
+<template>
+ <li class="list-item--attachment" :class="{'error' : attachment.error }">
+ <div class="attachment-preview">
+ <img v-if="attachment.imageBlobURL !== false" :src="attachment.imageBlobURL" class="attachment-preview-image">
+ <img v-else-if="attachment.hasPreview" :src="previewURL" class="attachment-preview-image">
+ <img v-else :src="getIcon" class="attachment-preview-image">
+ <span v-if="attachment.type === 'cloud'" class="cloud-attachment-icon">
+ <Cloud :size="16" />
+ </span>
+ </div>
+ <div class="attachment-inner">
+ <span class="new-message-attachment-name">
+ {{ attachment.displayName ? attachment.displayName : attachment.fileName }}
+ </span>
+ <span v-if="!attachment.finished" class="attachments-upload-progress">
+ <span class="attachments-upload-progress--bar" :style="&quot;width:&quot; + attachment.percent + &quot;%&quot;" />
+ </span>
+ <span v-else class="new-message-attachment-size">{{ attachment.sizeString }}</span>
+ </div>
+ <button @click="onDelete(attachment)">
+ <Close :size="24" />
+ </button>
+ </li>
+</template>
+
+<script>
+import { generateUrl } from '@nextcloud/router'
+import Close from 'vue-material-design-icons/Close'
+import Cloud from 'vue-material-design-icons/Cloud'
+
+export default {
+ name: 'ComposerAttachment',
+ components: {
+ Close,
+ Cloud,
+ },
+ props: {
+ bus: {
+ type: Object,
+ required: true,
+ },
+ attachment: {
+ type: Object,
+ required: true,
+ },
+ uploading: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ progress: 0,
+ sizeString: '',
+ finished: false,
+ }
+ },
+ computed: {
+ previewURL() {
+ if (this.attachment.hasPreview && this.attachment.id > 0) {
+ return generateUrl(`/core/preview?fileId=${this.attachment.id}&x=100&y=100&a=0`)
+ }
+ return ''
+ },
+ getIcon() {
+ return OC.MimeType.getIconUrl(this.attachment.fileType)
+ },
+ extension() {
+ return this.attachment.fileName.split('.').pop()
+ },
+ },
+ methods: {
+ onDelete(attachment) {
+ this.$emit('on-delete-attachment', attachment)
+ },
+ },
+
+}
+</script>
+
+<style lang="scss" scoped>
+
+.list-item--attachment {
+ width: calc(50% - 20px);
+ box-sizing: border-box;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin: 10px;
+ flex-wrap: wrap;
+
+ &.error {
+ color:red;
+ opacity: 0.5;
+ }
+
+ .cloud-attachment-icon {
+ position:absolute;
+ z-index: 2;
+ right: 2px;
+ top: 2px;
+ color: rgba(0, 0, 0, 1);
+ }
+
+ .attachment-preview {
+ display: inline-flex;
+ flex-wrap: wrap;
+ width: 50px;
+ height:50px;
+ overflow: hidden;
+ border-radius: 3px;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+
+ img {
+ display: block;
+ min-width: 50px;
+ min-height: 50px;
+ max-width: 72px;
+ max-height: 72px;
+ position: absolute;
+ }
+
+ span {
+ color: rgba(0,0,0,0.3);
+ font-size: 13px;
+ text-transform: uppercase;
+ font-weight: bold;
+ }
+
+ }
+
+ button {
+ padding: 0;
+ background: transparent;
+ border: none;
+ margin: 6px -2px 0 0;
+ }
+}
+
+a.list-item {
+ width:auto !important;
+}
+
+.attachments-upload-progress {
+ display: block;
+ height: 5px;
+ width: 100%;
+ position: relative;
+ border-radius: 5px;
+ background: var(--color-background-dark);
+ margin-top: 7px;
+
+ .attachments-upload-progress--bar {
+ height: 5px;
+ background: var(--color-primary-element-light);
+ position: absolute;
+ z-index: 1;
+ left: 0;
+ border-radius: 5px;
+ }
+}
+
+.attachments-upload-progress > div {
+ padding-left: 3px;
+}
+
+.new-message-attachments-action {
+ display: inline-block;
+ vertical-align: middle;
+ padding: 18px;
+ opacity: 0.5;
+}
+
+.attachment-inner {
+ display: flex;
+ flex-wrap: wrap;
+ width: calc(100% - 90px);
+ position: relative;
+
+}
+
+/* attachment filenames */
+.new-message-attachment-name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space:nowrap;
+ margin-bottom: 3px;
+}
+
+.new-message-attachment-size {
+ color: #6a6a6a;
+ width: 100%;
+}
+
+/* Colour the filename with a different color during attachment upload */
+.new-message-attachment-name.upload-ongoing {
+ color: #0082c9;
+}
+
+/* Colour the filename in red if the attachment upload failed */
+.new-message-attachment-name.upload-warning {
+ color: #d2322d;
+}
+
+/* Red ProgressBar for failed attachment uploads */
+.new-message-attachment-name.upload-warning .ui-progressbar-value {
+ border: 1px solid #e9322d;
+ background: #e9322d;
+}
+</style>
diff --git a/src/components/ComposerAttachments.vue b/src/components/ComposerAttachments.vue
index 28ae5b997..a8c5899a1 100644
--- a/src/components/ComposerAttachments.vue
+++ b/src/components/ComposerAttachments.vue
@@ -22,17 +22,22 @@
<template>
<div class="new-message-attachments">
- <ul>
- <li v-for="attachment in value" :key="attachment.id">
- <div class="new-message-attachment-name">
- {{ attachment.displayName ? attachment.displayName : attachment.fileName }}
- </div>
- <div class="new-message-attachments-action svg icon-delete" @click="onDelete(attachment)" />
- </li>
- <li v-if="uploading" class="attachments-upload-progress">
- <div :class="{'icon-loading-small': uploading}" />
- <div>{{ uploading ? t('mail', 'Uploading {percent}% …', {percent: uploadProgress}) : '' }}</div>
- </li>
+ <div v-if="hasNextLine" class="message-attachments-count" @click="isToggle = !isToggle">
+ <span>
+ {{ n('mail', '{count} attachment', '{count} attachments', attachments.length, { count: attachments.length }) }} ({{ formatBytes(totalSizeOfUpload()) }})
+ </span>
+ <ChevronUp v-if="isToggle" :size="24" />
+ <ChevronDown v-if="!isToggle" :size="24" />
+ </div>
+ <ul class="new-message-attachments--list" :class="isToggle ? 'hide' : (hasNextLine ? 'active' : '')">
+ <ComposerAttachment
+ v-for="attachment in attachments"
+ ref="attachments"
+ :key="attachment.id"
+ :bus="bus"
+ :attachment="attachment"
+ :uploading="uploading"
+ @on-delete-attachment="onDelete(attachment)" />
</ul>
<input ref="localAttachments"
@@ -50,19 +55,36 @@ import { getRequestToken } from '@nextcloud/auth'
import { formatFileSize } from '@nextcloud/files'
import prop from 'lodash/fp/prop'
import { getFilePickerBuilder, showWarning } from '@nextcloud/dialogs'
-import sum from 'lodash/fp/sum'
import sumBy from 'lodash/fp/sumBy'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import Vue from 'vue'
import logger from '../logger'
-import { getFileSize } from '../service/FileService'
+import { getFileData } from '../service/FileService'
import { shareFile } from '../service/FileSharingService'
import { uploadLocalAttachment } from '../service/AttachmentService'
+import ComposerAttachment from './ComposerAttachment.vue'
+
+import ChevronDown from 'vue-material-design-icons/ChevronDown'
+import ChevronUp from 'vue-material-design-icons/ChevronUp'
+
+const mimes = [
+ 'image/gif',
+ 'image/jpeg',
+ 'image/pjpeg',
+ 'image/png',
+ 'image/webp',
+]
+
export default {
name: 'ComposerAttachments',
+ components: {
+ ComposerAttachment,
+ ChevronDown,
+ ChevronUp,
+ },
props: {
value: {
type: Array,
@@ -81,6 +103,10 @@ export default {
return {
uploading: false,
uploads: {},
+ // this need if we want to pass in value only corrected uploaded files
+ attachments: [],
+ isToggle: false,
+ hasNextLine: false,
}
},
computed: {
@@ -93,11 +119,55 @@ export default {
}
return ((uploaded / total) * 100).toFixed(1)
},
+ total() {
+ let total = 0
+ for (const id in this.uploads) {
+ total += this.uploads[id].total
+ }
+ return total
+ },
+ },
+ watch: {
+ attachments() {
+ this.$nextTick(function() {
+ let prevTop = null
+ this.$refs.attachments.some((attachment, i) => {
+ const top = attachment.$el.getBoundingClientRect().top
+ if (prevTop !== null && prevTop !== top) {
+ if (!this.hasNextLine) {
+ this.isToggle = true
+ this.hasNextLine = true
+ }
+ return true
+ } else {
+ prevTop = top
+ if (this.$refs.attachments.length === i + 1) {
+ this.hasNextLine = false
+ this.isToggle = false
+ return true
+ }
+ }
+ return false
+ })
+ })
+ },
},
created() {
this.bus.$on('onAddLocalAttachment', this.onAddLocalAttachment)
this.bus.$on('onAddCloudAttachment', this.onAddCloudAttachment)
this.bus.$on('onAddCloudAttachmentLink', this.onAddCloudAttachmentLink)
+ this.value.map(attachment => {
+ this.attachments.push({
+ id: attachment.id,
+ fileName: attachment.fileName,
+ displayName: trimStart('/', attachment.fileName),
+ total: attachment.size,
+ finished: true,
+ sizeString: this.formatBytes(attachment.size),
+ imageBlobURL: attachment.isImage ? attachment.downloadUrl : attachment.mimeUrl,
+ })
+ return attachment
+ })
},
methods: {
onAddLocalAttachment() {
@@ -118,7 +188,7 @@ export default {
},
onLocalAttachmentSelected(e) {
this.uploading = true
-
+ // BUG - if choose again - progress lost/ move to complete()
Vue.set(this, 'uploads', {})
const toUpload = sumBy(prop('size'), Object.values(e.target.files))
@@ -131,32 +201,75 @@ export default {
})
if (this.uploadSizeLimit && newTotal > this.uploadSizeLimit) {
this.showAttachmentFileSizeWarning(e.target.files.length)
-
this.uploading = false
return
}
const progress = (id) => (prog, uploaded) => {
this.uploads[id].uploaded = uploaded
- }
+ this.attachments.map((item, i) => {
+ if (item.displayName === id) {
+ this.attachments[i].progress = uploaded
+ this.changeProgress(item, uploaded)
+ }
+ return item
+ })
+ }
+ // TODO bug: cancel axios on close or delete attachment
const promises = map((file) => {
- Vue.set(this.uploads, file.name, {
+ const controller = new AbortController()
+ this.attachments.push({
+ fileName: file.name,
+ fileType: file.type,
+ imageBlobURL: this.generatePreview(file),
+ displayName: trimStart('/', file.name),
+ progress: null,
+ percent: 0,
total: file.size,
- uploaded: 0,
+ finished: false,
+ error: false,
+ hasPreview: false,
+ controller,
})
- return uploadLocalAttachment(file, progress(file.name)).then(({ file, id }) => {
- logger.info('uploaded')
- this.emitNewAttachments([{
- fileName: file.name,
- displayName: trimStart('/', file.name),
- id,
- size: file.size,
- type: 'local',
- }])
+ Vue.set(this.uploads, file.name, {
+ total: file.size,
+ uploaded: 0,
})
+ try {
+ return uploadLocalAttachment(file, progress(file.name), controller)
+ .catch(() => {
+ this.attachments.some(attachment => {
+ if (attachment.displayName === file.name && !attachment.error) {
+ this.$set(attachment, 'error', true)
+ return true
+ }
+ return false
+ })
+ })
+ .then(({ file, id }) => {
+ logger.info('uploaded')
+ this.attachments.some((attachment, i) => {
+ if (attachment.displayName === file.name) {
+ this.attachments[i].id = id
+ this.attachments[i].finished = true
+ return true
+ }
+ return false
+ })
+
+ this.emitNewAttachments([{
+ fileName: file.name,
+ displayName: trimStart('/', file.name),
+ id,
+ size: file.size,
+ type: 'local',
+ }])
+ })
+ } catch (error) {
+ }
}, e.target.files)
const done = Promise.all(promises)
@@ -172,8 +285,14 @@ export default {
try {
const paths = await picker.pick(t('mail', 'Choose a file to add as attachment'))
- const fileSizes = await Promise.all(paths.map(getFileSize))
- const newTotal = sum(fileSizes) + this.totalSizeOfUpload()
+ // maybe fiiled front with placeholder loader...?
+ const filesFromCloud = await Promise.all(paths.map(getFileData))
+
+ const sum = filesFromCloud.reduce((sum, item) => {
+ return sum + item.size
+ }, 0)
+
+ const newTotal = sum + this.totalSizeOfUpload()
if (this.uploadSizeLimit && newTotal > this.uploadSizeLimit) {
this.showAttachmentFileSizeWarning(paths.length)
@@ -181,12 +300,28 @@ export default {
return
}
- this.emitNewAttachments(paths.map(function(name) {
- return {
+ this.emitNewAttachments(paths.map((name, i) => {
+ const _cloudFile = {
fileName: name,
displayName: trimStart('/', name),
type: 'cloud',
+ size: filesFromCloud[i].size,
+
+ }
+ const _toAttachmentData = {
+ finished: true,
+ imageBlobURL: this.generatePreview(_cloudFile),
+ total: filesFromCloud[i].size,
+ sizeString: this.formatBytes(filesFromCloud[i].size),
+ hasPreview: filesFromCloud[i]['has-preview'],
+ // dont know, may be it will be conflict if cloud & local has equal IDs?
+ id: filesFromCloud[i].fileid,
+ uploaded: 0,
}
+
+ this.attachments.push(Object.assign(_toAttachmentData, _cloudFile))
+
+ return _cloudFile
}))
} catch (error) {
logger.error('could not choose a file as attachment', { error })
@@ -216,14 +351,70 @@ export default {
))
},
onDelete(attachment) {
+ if (!attachment.finished) {
+ attachment.controller.abort()
+ }
+ const val = {
+ fileName: attachment.fileName,
+ displayName: attachment.displayName,
+ id: attachment.id,
+ size: attachment.total,
+ type: attachment.type,
+ }
+ const _att = this.attachments.filter((a) => {
+ return a !== attachment
+ })
+ this.attachments = _att
+
this.$emit(
'input',
- this.value.filter((a) => a !== attachment)
+ this.value.filter((a) => {
+ if (val.type === 'cloud') {
+ return a.fileName !== val.fileName
+ } else {
+ return a.id !== val.id
+ }
+
+ })
)
},
appendToBodyAtCursor(toAppend) {
this.bus.$emit('appendToBodyAtCursor', toAppend)
},
+ formatBytes(bytes, decimals = 2) {
+ if (bytes === 0) return '0 B'
+ const k = 1024
+ const dm = decimals < 0 ? 0 : decimals
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
+ },
+ changeProgress(item, progress) {
+ this.attachments.map((attachment, i) => {
+ if (item.fileName === attachment.fileName) {
+ if (!attachment.finished) {
+ const _progress = progress <= attachment.total ? progress : attachment.total
+ this.$set(attachment, 'progress', _progress)
+ this.$set(attachment, 'sizeString', this.formatBytes(_progress))
+ this.$set(attachment, 'percent', (_progress / attachment.total) * 100).toFixed(1)
+ if (item.total <= _progress) {
+ this.$set(attachment, 'finished', true)
+ }
+ }
+ }
+ return attachment
+ })
+ },
+ generatePreview(file) {
+ if (this.isImage(file)) {
+ return URL.createObjectURL(file)
+ } else {
+ return false
+ }
+ },
+ isImage(file) {
+ return file.type && mimes.indexOf(file.type) !== -1
+ },
},
}
</script>
@@ -231,51 +422,32 @@ export default {
<style scoped lang="scss">
.new-message-attachments {
- ul {
+ ul.new-message-attachments--list {
display: flex;
flex-wrap: wrap;
// 2 and a half attachment height
- max-height: 140px;
overflow: auto;
- }
- li {
- padding: 10px;
- }
-}
-
-.new-message-attachments-action {
- display: inline-block;
- vertical-align: middle;
- padding: 10px;
- opacity: 0.5;
-}
-
-/* attachment filenames */
-.new-message-attachment-name {
- display: inline-block;
-}
-
-/* Colour the filename with a different color during attachment upload */
-.new-message-attachment-name.upload-ongoing {
- color: #0082c9;
-}
+ transition: max-height 0.5s cubic-bezier(0, 1, 0, 1);
+ padding: 0 10px;
-/* Colour the filename in red if the attachment upload failed */
-.new-message-attachment-name.upload-warning {
- color: #d2322d;
-}
-
-/* Red ProgressBar for failed attachment uploads */
-.new-message-attachment-name.upload-warning .ui-progressbar-value {
- border: 1px solid #e9322d;
- background: #e9322d;
-}
+ &.hide {
+ overflow: hidden;
+ max-height:0;
+ transition: max-height 0.5s cubic-bezier(0, 1, 0, 1);
+ }
-.attachments-upload-progress {
- display: flex;
-}
+ &.active {
+ overflow: auto;
+ max-height: 287px;
+ }
+ }
-.attachments-upload-progress > div {
- padding-left: 3px;
+ .message-attachments-count {
+ color: var(--color-text-maxcontrast);
+ padding: 10px 20px;
+ cursor:pointer;
+ display:flex;
+ align-items: center;
+ }
}
</style>
diff --git a/src/service/AttachmentService.js b/src/service/AttachmentService.js
index 661919a6e..7f6ed1a9e 100644
--- a/src/service/AttachmentService.js
+++ b/src/service/AttachmentService.js
@@ -45,12 +45,15 @@ export function downloadAttachment(url) {
return Axios.get(url).then((res) => res.data)
}
-export const uploadLocalAttachment = (file, progress) => {
+export const uploadLocalAttachment = (file, progress, controller) => {
const url = generateUrl('/apps/mail/api/attachments')
const data = new FormData()
const opts = {
onUploadProgress: (prog) => progress(prog, prog.loaded, prog.total),
}
+ if (controller) {
+ opts.signal = controller.signal
+ }
data.append('attachment', file)
return Axios.post(url, data, opts)
diff --git a/src/service/FileService.js b/src/service/FileService.js
index 0acf2ab3d..d646c9b7b 100644
--- a/src/service/FileService.js
+++ b/src/service/FileService.js
@@ -35,3 +35,22 @@ export async function getFileSize(path) {
return response?.data?.props?.size
}
+
+export async function getFileData(path) {
+ const response = await getClient('files').stat(path, {
+ data: `<?xml version="1.0"?>
+ <d:propfind
+ xmlns:d="DAV:"
+ xmlns:oc="http://owncloud.org/ns"
+ xmlns:nc="http://nextcloud.org/ns">
+ <d:prop>
+ <oc:size />
+ <oc:fileid />
+ <nc:has-preview />
+ </d:prop>
+ </d:propfind>`,
+ details: true,
+ })
+
+ return response?.data?.props
+}