feat: drag and drop images

Allow up to 5 images to be dragged and dropped in to one-on-one chats and in the timeline. Can be combined with the existing upload button. The upload file dialog has been changed to allow multiple selections. Drag and dropped images adhere to the following rules, with corresponding validations messages:
- Max 5 image
- Image size must be 0.5 MB or less
- File extension must be one of [".png", ".jpg", ".jpeg", ".heif", "tif", ".tiff"]

Drag and drop and uploaded images are now also deduplicated.
This commit is contained in:
Eric Mastro 2021-03-10 15:59:01 +11:00 committed by Iuri Matias
parent de290727c2
commit f1e83f74bc
16 changed files with 444 additions and 94 deletions

View File

@ -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.}

View File

@ -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)

View File

@ -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))

View File

@ -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())

View File

@ -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){

View File

@ -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){

View File

@ -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

View File

@ -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"]
}

View File

@ -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))
}
}

View File

@ -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 {}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}
}
}

View File

@ -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('&nbsp;')));
insertInTextInput(0, Emoji.parse(words.join('&nbsp;')));
}
}
@ -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<validators.children.length; i++) {
const validator = validators.children[i]
validator.images = []
}
}
function showImageArea(imagePath) {
function validateImages(imagePaths) {
// needed because imageArea.imageSource is not a normal js array
const existing = (imageArea.imageSource || []).map(x => x.toString())
let validImages = Utils.deduplicate(existing.concat(imagePaths))
for (let i=0; i<validators.children.length; i++) {
const validator = validators.children[i]
validator.images = validImages
validImages = validImages.filter(validImage => 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)
}
}

View File

@ -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
}
}
}