diff --git a/src/app/chat/view.nim b/src/app/chat/view.nim index 1ce49da58f..3894468112 100644 --- a/src/app/chat/view.nim +++ b/src/app/chat/view.nim @@ -224,6 +224,24 @@ QtObject: error "Error sending the image", msg = e.msg result = fmt"Error sending the image: {e.msg}" + proc sendImages*(self: ChatsView, imagePathsArray: string): string {.slot.} = + result = "" + try: + var images = Json.decode(imagePathsArray, seq[string]) + let channelId = self.activeChannel.id + + for imagePath in images.mitems: + var image = image_utils.formatImagePath(imagePath) + imagePath = image_resizer(image, 2000, TMPDIR) + + self.status.chat.sendImages(channelId, images) + + for imagePath in images.items: + removeFile(imagePath) + except Exception as e: + error "Error sending images", msg = e.msg + result = fmt"Error sending images: {e.msg}" + proc activeChannelChanged*(self: ChatsView) {.signal.} proc contextChannelChanged*(self: ChatsView) {.signal.} diff --git a/src/app/utilsView/view.nim b/src/app/utilsView/view.nim index 2368c60c77..5965727efc 100644 --- a/src/app/utilsView/view.nim +++ b/src/app/utilsView/view.nim @@ -10,6 +10,7 @@ import ../../status/libstatus/settings import ../../status/libstatus/wallet as status_wallet import ../../status/libstatus/utils as status_utils import ../../status/ens as status_ens +import ../utils/image_utils import web3/[ethtypes, conversions] import stew/byteutils @@ -99,3 +100,13 @@ QtObject: proc getNetworkName*(self: UtilsView): string {.slot.} = getCurrentNetworkDetails().name + + proc getFileSize*(self: UtilsView, filename: string): string {.slot.} = + var f: File = nil + if f.open(filename.formatImagePath): + try: + result = $(f.getFileSize()) + finally: + close(f) + else: + raise newException(IOError, "cannot open: " & filename) diff --git a/src/status/chat.nim b/src/status/chat.nim index 265bcae77d..5884d98180 100644 --- a/src/status/chat.nim +++ b/src/status/chat.nim @@ -247,6 +247,10 @@ proc sendImage*(self: ChatModel, chatId: string, image: string) = var response = status_chat.sendImageMessage(chatId, image) discard self.processMessageUpdateAfterSend(response) +proc sendImages*(self: ChatModel, chatId: string, images: var seq[string]) = + var response = status_chat.sendImageMessages(chatId, images) + discard self.processMessageUpdateAfterSend(response) + proc sendSticker*(self: ChatModel, chatId: string, sticker: Sticker) = var response = status_chat.sendStickerMessage(chatId, sticker) self.events.emit("stickerSent", StickerArgs(sticker: sticker, save: true)) diff --git a/src/status/libstatus/chat.nim b/src/status/libstatus/chat.nim index fff7d1157e..0ff51012c8 100644 --- a/src/status/libstatus/chat.nim +++ b/src/status/libstatus/chat.nim @@ -166,6 +166,22 @@ proc sendImageMessage*(chatId: string, image: string): string = } ]) +proc sendImageMessages*(chatId: string, images: var seq[string]): string = + let + preferredUsername = getSetting[string](Setting.PreferredUsername, "") + debugEcho ">>> [status/libstatus/chat.sendImageMessages] about to send images" + let imagesJson = %* images.map(image => %* + { + "chatId": chatId, + "contentType": ContentType.Image.int, + "imagePath": image, + "ensName": preferredUsername, + "text": "Update to latest version to see a nice image here!" + } + ) + debugEcho ">>> [status/libstatus/chat.sendImageMessages] imagesJson:", $imagesJson + callPrivateRPC("sendChatMessages".prefix, %* [imagesJson]) + proc sendStickerMessage*(chatId: string, sticker: Sticker): string = let preferredUsername = getSetting[string](Setting.PreferredUsername, "") callPrivateRPC("sendChatMessage".prefix, %* [ @@ -358,10 +374,9 @@ proc pendingRequestsToJoinForCommunity*(communityId: string): seq[CommunityMembe proc myPendingRequestsToJoin*(): seq[CommunityMembershipRequest] = let rpcResult = callPrivateRPC("myPendingRequestsToJoin".prefix).parseJSON() - var communityRequests: seq[CommunityMembershipRequest] = @[] - if rpcResult{"result"}.kind != JNull: + if rpcResult{"error"}.kind == JNull and rpcResult{"result"}.kind != JNull: for jsonCommunityReqest in rpcResult["result"]: communityRequests.add(jsonCommunityReqest.toCommunityMembershipRequest()) diff --git a/ui/app/AppLayouts/Chat/ChatColumn.qml b/ui/app/AppLayouts/Chat/ChatColumn.qml index 901a683180..ef0b50bd9e 100644 --- a/ui/app/AppLayouts/Chat/ChatColumn.qml +++ b/ui/app/AppLayouts/Chat/ChatColumn.qml @@ -317,7 +317,7 @@ StackLayout { } onSendMessage: { if (chatInput.fileUrls.length > 0){ - chatsModel.sendImage(chatInput.fileUrls[0], false); + chatsModel.sendImages(JSON.stringify(fileUrls)); } let msg = chatsModel.plainText(Emoji.deparse(chatInput.textInput.text)) if (msg.length > 0){ diff --git a/ui/app/AppLayouts/Timeline/TimelineLayout.qml b/ui/app/AppLayouts/Timeline/TimelineLayout.qml index 6ec1210e2b..ad03065cb2 100644 --- a/ui/app/AppLayouts/Timeline/TimelineLayout.qml +++ b/ui/app/AppLayouts/Timeline/TimelineLayout.qml @@ -71,9 +71,13 @@ ScrollView { anchors.top: parent.top anchors.topMargin: 40 chatType: Constants.chatTypeStatusUpdate + imageErrorMessageLocation: StatusChatInput.ImageErrorMessageLocation.Bottom + z: 1 onSendMessage: { if (statusUpdateInput.fileUrls.length > 0){ - chatsModel.sendImage(statusUpdateInput.fileUrls[0], true); + statusUpdateInput.fileUrls.forEach(url => { + chatsModel.sendImage(url, true); + }) } var msg = chatsModel.plainText(Emoji.deparse(statusUpdateInput.textInput.text)) if (msg.length > 0){ diff --git a/ui/app/AppMain.qml b/ui/app/AppMain.qml index 507e5a13cb..41371f67ae 100644 --- a/ui/app/AppMain.qml +++ b/ui/app/AppMain.qml @@ -15,6 +15,7 @@ import Qt.labs.settings 1.0 RowLayout { id: appMain + property int currentView: sLayout.currentIndex spacing: 0 Layout.fillHeight: true Layout.fillWidth: true diff --git a/ui/imports/Constants.qml b/ui/imports/Constants.qml index 80fcb8ee73..eb37bf7440 100644 --- a/ui/imports/Constants.qml +++ b/ui/imports/Constants.qml @@ -124,4 +124,10 @@ QtObject { readonly property string deepLinkPrefix: 'statusim://' readonly property string joinStatusLink: 'join.status.im' + + readonly property int maxUploadFiles: 5 + readonly property double maxUploadFilesizeMB: 0.5 + + readonly property var acceptedImageExtensions: [".png", ".jpg", ".jpeg", ".svg", ".gif"] + readonly property var acceptedDragNDropImageExtensions: [".png", ".jpg", ".jpeg", ".heif", "tif", ".tiff"] } diff --git a/ui/imports/Utils.qml b/ui/imports/Utils.qml index 56a03b7228..e49404839c 100644 --- a/ui/imports/Utils.qml +++ b/ui/imports/Utils.qml @@ -404,6 +404,14 @@ QtObject { } function hasImageExtension(url) { - return [".png", ".jpg", ".jpeg", ".svg", ".gif"].some(ext => url.includes(ext)) + return Constants.acceptedImageExtensions.some(ext => url.includes(ext)) + } + + function hasDragNDropImageExtension(url) { + return Constants.acceptedDragNDropImageExtensions.some(ext => url.includes(ext)) + } + + function deduplicate(array) { + return Array.from(new Set(array)) } } diff --git a/ui/main.qml b/ui/main.qml index 69b25f67b1..276ef511f8 100644 --- a/ui/main.qml +++ b/ui/main.qml @@ -18,6 +18,7 @@ import "./imports" ApplicationWindow { property bool hasAccounts: !!loginModel.rowCount() property bool removeMnemonicAfterLogin: false + property alias dragAndDrop: dragTarget Universal.theme: Universal.System @@ -247,6 +248,85 @@ ApplicationWindow { property var appSettings } + DropArea { + id: dragTarget + + signal droppedOnValidScreen(var drop) + property alias droppedUrls: rptDraggedPreviews.model + readonly property int chatView: Utils.getAppSectionIndex(Constants.chat) + readonly property int timelineView: Utils.getAppSectionIndex(Constants.timeline) + property bool enabled: containsDrag && loader.item && + ( + // in chat view + (loader.item.currentView === chatView && + ( + // in a one-to-one chat + chatsModel.activeChannel.chatType === Constants.chatTypeOneToOne || + // in a private group chat + chatsModel.activeChannel.chatType === Constants.chatTypePrivateGroupChat + ) + ) || + // in timeline view + loader.item.currentView === timelineView + ) + + width: applicationWindow.width + height: applicationWindow.height + + function cleanup() { + rptDraggedPreviews.model = [] + } + + onDropped: (drop) => { + if (enabled) { + droppedOnValidScreen(drop) + } + cleanup() + } + onEntered: { + // needed because drag.urls is not a normal js array + rptDraggedPreviews.model = drag.urls.filter(img => Utils.hasDragNDropImageExtension(img)) + } + onPositionChanged: { + rptDraggedPreviews.x = drag.x + rptDraggedPreviews.y = drag.y + } + onExited: cleanup() + Rectangle { + id: dropRectangle + + width: parent.width + height: parent.height + color: Style.current.transparent + opacity: 0.8 + + states: [ + State { + when: dragTarget.enabled + PropertyChanges { + target: dropRectangle + color: Style.current.background + } + } + ] + } + Repeater { + id: rptDraggedPreviews + + Image { + source: modelData + width: 80 + height: 80 + sourceSize.width: 160 + sourceSize.height: 160 + fillMode: Image.PreserveAspectFit + x: index * 10 + rptDraggedPreviews.x + y: index * 10 + rptDraggedPreviews.y + z: 1 + } + } + } + Component { id: app AppMain {} diff --git a/ui/shared/status/StatusChatImageExtensionValidator.qml b/ui/shared/status/StatusChatImageExtensionValidator.qml new file mode 100644 index 0000000000..510570411f --- /dev/null +++ b/ui/shared/status/StatusChatImageExtensionValidator.qml @@ -0,0 +1,23 @@ +import QtQuick 2.13 +import QtQuick.Controls 2.13 +import QtQuick.Layouts 1.13 +import QtGraphicalEffects 1.0 +import "../../imports" +import ".." + +StatusChatImageValidator { + id: root + + errorMessage: qsTr("Format not supported.") + secondaryErrorMessage: qsTr("Upload %1 only").arg(Constants.acceptedDragNDropImageExtensions.map(ext => ext.replace(".", "").toUpperCase() + "s").join(", ")) + + onImagesChanged: { + let isValid = true + root.validImages = images.filter(img => { + const isImage = Utils.hasDragNDropImageExtension(img) + isValid = isValid && isImage + return isImage + }) + root.isValid = isValid + } +} diff --git a/ui/shared/status/StatusChatImageQtyValidator.qml b/ui/shared/status/StatusChatImageQtyValidator.qml new file mode 100644 index 0000000000..2c4667f9e9 --- /dev/null +++ b/ui/shared/status/StatusChatImageQtyValidator.qml @@ -0,0 +1,20 @@ +import QtQuick 2.13 +import QtQuick.Controls 2.13 +import QtQuick.Layouts 1.13 +import QtGraphicalEffects 1.0 +import "../../imports" +import ".." + +StatusChatImageValidator { + id: root + errorMessage: qsTr("You can only upload %1 images at a time").arg(Constants.maxUploadFiles) + + onImagesChanged: { + let isValid = true + if (images.length > Constants.maxUploadFiles) { + isValid = false + } + root.isValid = isValid + root.validImages = images.slice(0, Constants.maxUploadFiles) + } +} diff --git a/ui/shared/status/StatusChatImageSizeValidator.qml b/ui/shared/status/StatusChatImageSizeValidator.qml new file mode 100644 index 0000000000..27a1f252f0 --- /dev/null +++ b/ui/shared/status/StatusChatImageSizeValidator.qml @@ -0,0 +1,23 @@ +import QtQuick 2.13 +import QtQuick.Controls 2.13 +import QtQuick.Layouts 1.13 +import QtGraphicalEffects 1.0 +import "../../imports" +import ".." + +StatusChatImageValidator { + id: root + readonly property int maxImgSizeBytes: Constants.maxUploadFilesizeMB * 1048576 /* 1 MB in bytes */ + + onImagesChanged: { + let isValid = true + root.validImages = images.filter(img => { + let size = parseInt(utilsModel.getFileSize(img)) + const isSmallEnough = size <= maxImgSizeBytes + isValid = isValid && isSmallEnough + return isSmallEnough + }) + root.isValid = isValid + } + errorMessage: qsTr("Max image size is %1 MB").arg(Constants.maxUploadFilesizeMB) +} \ No newline at end of file diff --git a/ui/shared/status/StatusChatImageValidator.qml b/ui/shared/status/StatusChatImageValidator.qml new file mode 100644 index 0000000000..e63d2b2db7 --- /dev/null +++ b/ui/shared/status/StatusChatImageValidator.qml @@ -0,0 +1,73 @@ +import QtQuick 2.13 +import QtQuick.Controls 2.13 +import QtQuick.Layouts 1.13 +import QtGraphicalEffects 1.0 +import "../../imports" +import ".." + +Item { + id: root + property bool isValid: true + property alias errorMessage: txtValidationError.text + property alias secondaryErrorMessage: txtValidationExtraInfo.text + property var images: [] + property var validImages: [] + + visible: !isValid + width: imgExclamation.width + txtValidationError.width + txtValidationExtraInfo.width + 24 + height: txtValidationError.height + 14 + + Rectangle { + anchors.fill: parent + color: Style.current.background + radius: Style.current.halfPadding + layer.enabled: true + layer.effect: DropShadow { + verticalOffset: 3 + radius: 8 + samples: 15 + fast: true + cached: true + color: "#22000000" + } + + SVGImage { + id: imgExclamation + width: 20 + height: 20 + sourceSize.height: height * 2 + sourceSize.width: width * 2 + verticalAlignment: Image.AlignVCenter + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: 6 + anchors.leftMargin: 6 + fillMode: Image.PreserveAspectFit + source: "../../app/img/exclamation_outline.svg" + } + StyledText { + id: txtValidationError + verticalAlignment: Text.AlignVCenter + anchors.top: parent.top + anchors.left: imgExclamation.right + anchors.topMargin: 7 + anchors.leftMargin: 6 + wrapMode: Text.WordWrap + font.pixelSize: 13 + height: 18 + color: Style.current.danger + } + StyledText { + id: txtValidationExtraInfo + verticalAlignment: Text.AlignVCenter + anchors.top: parent.top + anchors.left: txtValidationError.right + anchors.topMargin: 7 + anchors.leftMargin: 6 + wrapMode: Text.WordWrap + font.pixelSize: 13 + height: 18 + color: Style.current.textColor + } + } +} diff --git a/ui/shared/status/StatusChatInput.qml b/ui/shared/status/StatusChatInput.qml index 27818b5aa8..dd25ae1889 100644 --- a/ui/shared/status/StatusChatInput.qml +++ b/ui/shared/status/StatusChatInput.qml @@ -44,6 +44,13 @@ Rectangle { property alias suggestionsList: suggestions property alias suggestions: suggestionsBox + property var imageErrorMessageLocation: StatusChatInput.ImageErrorMessageLocation.Top + + enum ImageErrorMessageLocation { + Top, + Bottom + } + height: { if (extendedArea.visible) { return messageInput.height + extendedArea.height + (control.isStatusUpdateInput ? 0 : Style.current.bigPadding) @@ -120,6 +127,7 @@ Rectangle { if (messageInputField.length < messageLimit) { control.sendMessage(event) control.hideExtendedArea(); + event.accepted = true return; } if(event) event.accepted = true @@ -255,7 +263,7 @@ Rectangle { if (madeChanges) { messageInputField.remove(0, messageInputField.length); - insertInTextInput(0, Emoji.parse(words.join(' '))); + insertInTextInput(0, Emoji.parse(words.join(' '))); } } @@ -388,17 +396,33 @@ Rectangle { isImage = false; isReply = false; control.fileUrls = [] - imageArea.imageSource = ""; + imageArea.imageSource = []; replyArea.userName = "" replyArea.identicon = "" replyArea.message = "" + for (let i=0; i x.toString()) + let validImages = Utils.deduplicate(existing.concat(imagePaths)) + for (let i=0; i validator.validImages.includes(validImage)) + } + return validImages + } + + function showImageArea(imagePaths) { isImage = true; isReply = false; - control.fileUrls = imageDialog.fileUrls - imageArea.imageSource = control.fileUrls[0] + imageArea.imageSource = imagePaths + control.fileUrls = imageArea.imageSource } function showReplyArea(userName, message, identicon) { @@ -409,24 +433,37 @@ Rectangle { messageInputField.forceActiveFocus(); } + Connections { + target: applicationWindow.dragAndDrop + onDroppedOnValidScreen: (drop) => { + let validImages = validateImages(drop.urls) + if (validImages.length > 0) { + showImageArea(validImages) + drop.acceptProposedAction() + } + } + } + ListModel { id: suggestions } - FileDialog { id: imageDialog //% "Please choose an image" title: qsTrId("please-choose-an-image") folder: shortcuts.pictures + selectMultiple: true nameFilters: [ - //% "Image files (*.jpg *.jpeg *.png)" - qsTrId("image-files----jpg---jpeg---png-") + qsTr("Image files (%1)").arg(Constants.acceptedDragNDropImageExtensions.map(img => "*" + img).join(" ")) ] onAccepted: { imageBtn.highlighted = false imageBtn2.highlighted = false - control.showImageArea() + let validImages = validateImages(imageDialog.fileUrls) + if (validImages.length > 0) { + control.showImageArea(validImages) + } messageInputField.forceActiveFocus(); } onRejected: { @@ -582,6 +619,26 @@ Rectangle { radius: control.isStatusUpdateInput ? 36 : height > defaultInputFieldHeight + 1 || extendedArea.visible ? 16 : 32 + ColumnLayout { + id: validators + anchors.bottom: control.imageErrorMessageLocation === StatusChatInput.ImageErrorMessageLocation.Top ? extendedArea.top : undefined + anchors.bottomMargin: control.imageErrorMessageLocation === StatusChatInput.ImageErrorMessageLocation.Top ? -4 : undefined + anchors.top: control.imageErrorMessageLocation === StatusChatInput.ImageErrorMessageLocation.Bottom ? extendedArea.bottom : undefined + anchors.topMargin: control.imageErrorMessageLocation === StatusChatInput.ImageErrorMessageLocation.Bottom ? (isImage ? -4 : 4) : undefined + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width + z: 1 + StatusChatImageExtensionValidator { + Layout.alignment: Qt.AlignHCenter + } + StatusChatImageSizeValidator { + Layout.alignment: Qt.AlignHCenter + } + StatusChatImageQtyValidator { + Layout.alignment: Qt.AlignHCenter + } + } + Rectangle { id: extendedArea visible: isImage || isReply @@ -625,9 +682,13 @@ Rectangle { anchors.top: parent.top anchors.topMargin: control.isStatusUpdateInput ? 0 : Style.current.halfPadding visible: isImage + width: messageInputField.width - actions.width onImageRemoved: { - control.fileUrls = [] - isImage = false + if (control.fileUrls.length > index && control.fileUrls[index]) { + control.fileUrls.splice(index, 1) + } + isImage = control.fileUrls.length > 0 + validateImages(control.fileUrls) } } diff --git a/ui/shared/status/StatusChatInputImageArea.qml b/ui/shared/status/StatusChatInputImageArea.qml index 46bffd9998..af2cad1dc0 100644 --- a/ui/shared/status/StatusChatInputImageArea.qml +++ b/ui/shared/status/StatusChatInputImageArea.qml @@ -4,90 +4,93 @@ import QtQuick.Controls 2.13 import "../../imports" import "../../shared" -Rectangle { +Row { id: imageArea - height: chatImage.height + spacing: Style.current.halfPadding - signal imageRemoved() - property url imageSource: "" - color: "transparent" - - Image { - id: chatImage - property bool hovered: false - height: 64 - anchors.left: parent.left - anchors.top: parent.top - fillMode: Image.PreserveAspectFit - mipmap: true - smooth: false - antialiasing: true - source: parent.imageSource - MouseArea { - cursorShape: Qt.PointingHandCursor - anchors.fill: parent - hoverEnabled: true - onEntered: { - chatImage.hovered = true - } - onExited: { - chatImage.hovered = false - } - } + signal imageRemoved(int index) + property alias imageSource: rptImages.model - layer.enabled: true - layer.effect: OpacityMask { - maskSource: Item { - width: chatImage.width - height: chatImage.height - - Rectangle { - anchors.top: parent.top - anchors.left: parent.left - width: chatImage.width - height: chatImage.height - radius: 16 + Repeater { + id: rptImages + Item { + height: chatImage.paintedHeight + closeBtn.height - 5 + width: chatImage.width + Image { + id: chatImage + property bool hovered: false + width: 64 + height: 64 + fillMode: Image.PreserveAspectCrop + mipmap: true + smooth: false + antialiasing: true + source: modelData + MouseArea { + cursorShape: Qt.PointingHandCursor + anchors.fill: parent + hoverEnabled: true + onEntered: { + chatImage.hovered = true + } + onExited: { + chatImage.hovered = false + } } - Rectangle { - anchors.bottom: parent.bottom - anchors.right: parent.right - width: 32 - height: 32 - radius: 4 + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Item { + width: chatImage.width + height: chatImage.height + + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + width: chatImage.width + height: chatImage.height + radius: 16 + } + Rectangle { + anchors.bottom: parent.bottom + anchors.right: parent.right + width: 32 + height: 32 + radius: 4 + } + } + } + } + RoundButton { + id: closeBtn + implicitWidth: 24 + implicitHeight: 24 + padding: 0 + anchors.top: chatImage.top + anchors.topMargin: -5 + anchors.right: chatImage.right + anchors.rightMargin: -Style.current.halfPadding + visible: chatImage.hovered || hovered + contentItem: SVGImage { + source: !closeBtn.hovered ? + "../../app/img/close-filled.svg" : "../../app/img/close-filled-hovered.svg" + width: closeBtn.width + height: closeBtn.height + } + background: Rectangle { + color: "transparent" + } + onClicked: { + imageArea.imageRemoved(index) + const tmp = imageArea.imageSource.filter((url, idx) => idx !== index) + rptImages.model = tmp + } + MouseArea { + cursorShape: Qt.PointingHandCursor + anchors.fill: parent + onPressed: mouse.accepted = false } } } } - - RoundButton { - id: closeBtn - implicitWidth: 24 - implicitHeight: 24 - padding: 0 - anchors.top: chatImage.top - anchors.topMargin: -5 - anchors.right: chatImage.right - anchors.rightMargin: -Style.current.halfPadding - visible: chatImage.hovered || hovered - contentItem: SVGImage { - source: !closeBtn.hovered ? - "../../app/img/close-filled.svg" : "../../app/img/close-filled-hovered.svg" - width: closeBtn.width - height: closeBtn.height - } - background: Rectangle { - color: "transparent" - } - onClicked: { - imageArea.imageRemoved() - imageArea.imageSource = "" - } - MouseArea { - cursorShape: Qt.PointingHandCursor - anchors.fill: parent - onPressed: mouse.accepted = false - } - } - - }