From c9c47d623b8e2daecef64ac2ec963e464c2080b5 Mon Sep 17 00:00:00 2001 From: Sebin Song Date: Tue, 3 Dec 2024 16:09:45 +1300 Subject: [PATCH] #2429 - File attachment in chat should show the size (#2435) * determine if an image needs compression or not * implement compressImage() function * write a function that checks if image/webp format is supported * implement recursive compression logic in compressImage() * optimise compression factors / some corrections on file details post compression * update the minimum quality * fix the typo iamge * do not resize the image unless its physical size is too large * attachment data in contracts needs size field * file-size to a human readable string / display it in multiple UIs * update according to feedback * update maximum image dimension * remove console.log / revert back webP format as default * add a constant for kilo-byte * updates for PR review * file size update after image compression --- frontend/controller/actions/identity.js | 1 + frontend/model/contracts/shared/types.js | 29 ++++++++------- .../views/containers/chatroom/SendArea.vue | 1 + .../file-attachment/ChatAttachmentPreview.vue | 37 ++++++++++++++++--- frontend/views/utils/filters.js | 14 +++++++ 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 8ab7634513..3fcd3a745f 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -600,6 +600,7 @@ export default (sbp('sbp/selectors/register', { attachment.mimeType = attachmentBlob.type attachment.name = `${fileNameWithoutExtension}.${extension}` + attachment.size = attachmentBlob.size } const response = await sbp('chelonia/fileUpload', attachmentBlob, { type: attachment.mimeType, diff --git a/frontend/model/contracts/shared/types.js b/frontend/model/contracts/shared/types.js index 2e9ae671be..37c79cc260 100644 --- a/frontend/model/contracts/shared/types.js +++ b/frontend/model/contracts/shared/types.js @@ -2,7 +2,7 @@ import { objectOf, objectMaybeOf, arrayOf, unionOf, boolean, - object, string, stringMax, optional, number, mapOf, literalOf + object, string, stringMax, optional, number, mapOf, literalOf, numberRange } from '~/frontend/model/contracts/misc/flowTyper.js' import { CHATROOM_TYPES, @@ -64,18 +64,21 @@ export const messageType: any = objectMaybeOf({ type: unionOf(...Object.values(MESSAGE_NOTIFICATIONS).map(v => literalOf(v))), params: mapOf(string, string) // { username } }), - attachments: arrayOf(objectOf({ - name: string, - mimeType: string, - dimension: optional(objectOf({ - width: number, - height: number - })), - downloadData: objectOf({ - manifestCid: string, - downloadParams: optional(object) - }) - })), + attachments: optional( + arrayOf(objectOf({ + name: string, + mimeType: string, + size: numberRange(1, Number.MAX_SAFE_INTEGER), + dimension: optional(objectOf({ + width: number, + height: number + })), + downloadData: objectOf({ + manifestCid: string, + downloadParams: optional(object) + }) + })) + ), replyingMessage: objectOf({ hash: string, // scroll to the original message and highlight text: string // display text(if too long, truncate) diff --git a/frontend/views/containers/chatroom/SendArea.vue b/frontend/views/containers/chatroom/SendArea.vue index 5376dda169..e3f6700a60 100644 --- a/frontend/views/containers/chatroom/SendArea.vue +++ b/frontend/views/containers/chatroom/SendArea.vue @@ -750,6 +750,7 @@ export default ({ url: fileUrl, name: file.name, mimeType: file.type || '', + size: fileSize, downloadData: null // NOTE: we can tell if the attachment has been uploaded by seeing if this field is non-null. } diff --git a/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue b/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue index bfbc987357..39af0e8b1e 100644 --- a/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue +++ b/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue @@ -15,7 +15,9 @@ .c-non-image-file-info .c-file-name.has-ellipsis {{ entry.name }} - .c-file-ext {{ fileExt(entry) }} + .c-file-ext-and-size + .c-file-ext {{ fileExt(entry) }} + .c-file-size(v-if='entry.size') {{ fileSizeDisplay(entry) }} .c-preview-img(v-else) img( @@ -36,7 +38,7 @@ .c-attachment-actions tooltip( direction='top' - :text='L("Download")' + :text='getDownloadTooltipText(entry)' ) button.is-icon-small( :aria-label='L("Download")' @@ -91,9 +93,10 @@ import sbp from '@sbp/sbp' import Tooltip from '@components/Tooltip.vue' import { MESSAGE_VARIANTS } from '@model/contracts/shared/constants.js' -import { getFileExtension, getFileType } from '@view-utils/filters.js' +import { getFileExtension, getFileType, formatBytesDecimal } from '@view-utils/filters.js' import { Secret } from '~/shared/domains/chelonia/Secret.js' import { OPEN_MODAL } from '@utils/events.js' +import { L } from '@common/common.js' import { randomHexString } from '@model/contracts/shared/giLodash.js' export default { @@ -155,6 +158,14 @@ export default { fileExt ({ name }) { return getFileExtension(name, true) }, + fileSizeDisplay ({ size }) { + return size ? formatBytesDecimal(size) : '' + }, + getDownloadTooltipText ({ size }) { + return this.shouldPreviewImages + ? `${L('Download ({size})', { size: formatBytesDecimal(size) })}` + : L('Download') + }, fileType ({ mimeType }) { return getFileType(mimeType) }, @@ -276,8 +287,22 @@ export default { &.is-for-download { padding: 0; - .c-preview-non-image .c-non-image-file-info { - width: calc(100% - 4rem); + .c-preview-non-image { + .c-non-image-file-info { + width: calc(100% - 4rem); + } + + .c-file-ext-and-size { + display: flex; + align-items: flex-end; + flex-direction: row; + column-gap: 0.325rem; + } + + .c-file-size { + color: $text_1; + font-size: 0.8em; + } } .c-attachment-actions-wrapper { @@ -310,6 +335,8 @@ export default { .is-download-item { &:hover .c-attachment-actions-wrapper { display: flex; + flex-direction: column; + align-items: flex-end; } .c-preview-non-image { diff --git a/frontend/views/utils/filters.js b/frontend/views/utils/filters.js index f2642435d1..f5abfd0819 100644 --- a/frontend/views/utils/filters.js +++ b/frontend/views/utils/filters.js @@ -1,3 +1,5 @@ +import { L } from '@common/common.js' + export const toPercent = (decimal: number): number => Math.floor(decimal * 100) export const getFileExtension = ( @@ -15,6 +17,18 @@ export const getFileType = ( return mimeType.match('image/') ? 'image' : 'non-image' } +export const formatBytesDecimal = (bytes: number, decimals: number = 2): string => { + if (bytes < 0 || !Number.isFinite(bytes)) return L('Invalid size') + else if (bytes === 0) return L('0 Bytes') + + const k = 1024 // Decimal base + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + const formattedValue = parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + return `${formattedValue} ${sizes[i]}` +} + /** * this function filters `list` by `keyword` * `list` should be an array of objects and strings