diff --git a/src/app/chat/views/communities.nim b/src/app/chat/views/communities.nim index 6be4e10ffe..d48acd2011 100644 --- a/src/app/chat/views/communities.nim +++ b/src/app/chat/views/communities.nim @@ -74,7 +74,7 @@ QtObject: proc pendingRequestsToJoinForCommunity*(self: CommunitiesView, communityId: string): seq[CommunityMembershipRequest] = result = self.status.chat.pendingRequestsToJoinForCommunity(communityId) - proc membershipRequestPushed*(self: CommunitiesView, communityName: string, pubKey: string) {.signal.} + proc membershipRequestPushed*(self: CommunitiesView, communityId: string, communityName: string, pubKey: string) {.signal.} proc addMembershipRequests*(self: CommunitiesView, membershipRequests: seq[CommunityMembershipRequest]) = var communityId: string @@ -87,7 +87,7 @@ QtObject: let alreadyPresentRequestIdx = community.membershipRequests.findIndexById(request.id) if (alreadyPresentRequestIdx == -1): community.membershipRequests.add(request) - self.membershipRequestPushed(community.name, request.publicKey) + self.membershipRequestPushed(community.id, community.name, request.publicKey) else: community.membershipRequests[alreadyPresentRequestIdx] = request self.joinedCommunityList.replaceCommunity(community) @@ -171,7 +171,7 @@ QtObject: error "Error joining the community", msg = e.msg result = fmt"Error joining the community: {e.msg}" - proc membershipRequestChanged*(self: CommunitiesView, communityName: string, accepted: bool) {.signal.} + proc membershipRequestChanged*(self: CommunitiesView, communityId: string, communityName: string, accepted: bool) {.signal.} proc communityAdded*(self: CommunitiesView, communityId: string) {.signal.} @@ -200,7 +200,7 @@ QtObject: var i = 0 for communityRequest in self.myCommunityRequests: if (communityRequest.communityId == community.id): - self.membershipRequestChanged(community.name, true) + self.membershipRequestChanged(community.id, community.name, true) self.myCommunityRequests.delete(i, i) break i = i + 1 diff --git a/src/app/chat/views/community_item.nim b/src/app/chat/views/community_item.nim index 06e3c2d246..c5d15af08a 100644 --- a/src/app/chat/views/community_item.nim +++ b/src/app/chat/views/community_item.nim @@ -49,6 +49,8 @@ QtObject: proc activeChanged*(self: CommunityItemView) {.signal.} proc setActive*(self: CommunityItemView, value: bool) {.slot.} = + if (self.active == value): + return self.active = value self.status.events.emit("communityActiveChanged", CommunityActiveChangedArgs(active: value)) self.activeChanged() diff --git a/src/app/profile/core.nim b/src/app/profile/core.nim index 95919e7d39..68812e47ee 100644 --- a/src/app/profile/core.nim +++ b/src/app/profile/core.nim @@ -120,6 +120,7 @@ proc init*(self: ProfileController, account: Account) = # TODO: view should react to model changes self.status.chat.updateContacts(msgData.contacts) self.view.contacts.updateContactList(msgData.contacts) + self.view.contacts.notifyOnNewContactRequests(msgData.contacts) if msgData.installations.len > 0: self.view.devices.addDevices(msgData.installations) diff --git a/src/app/profile/views/contact_list.nim b/src/app/profile/views/contact_list.nim index 69dc7355f0..b6635936a7 100644 --- a/src/app/profile/views/contact_list.nim +++ b/src/app/profile/views/contact_list.nim @@ -16,6 +16,7 @@ type LocalNickname = UserRole + 9 ThumbnailImage = UserRole + 10 LargeImage = UserRole + 11 + RequestReceived = UserRole + 12 QtObject: type ContactList* = ref object of QAbstractListModel @@ -38,6 +39,15 @@ QtObject: method rowCount(self: ContactList, index: QModelIndex = nil): int = return self.contacts.len + proc countChanged*(self: ContactList) {.signal.} + + proc count*(self: ContactList): int {.slot.} = + self.contacts.len + + QtProperty[int] count: + read = count + notify = countChanged + proc userName*(self: ContactList, pubKey: string, defaultValue: string = ""): string {.slot.} = for contact in self.contacts: if(contact.id != pubKey): continue @@ -66,6 +76,7 @@ QtObject: of "localNickname": result = $contact.localNickname of "thumbnailImage": result = $contact.identityImage.thumbnail of "largeImage": result = $contact.identityImage.large + of "requestReceived": result = $contact.requestReceived() method data(self: ContactList, index: QModelIndex, role: int): QVariant = if not index.isValid: @@ -85,6 +96,7 @@ QtObject: of ContactRoles.LocalNickname: result = newQVariant(contact.localNickname) of ContactRoles.ThumbnailImage: result = newQVariant(contact.identityImage.thumbnail) of ContactRoles.LargeImage: result = newQVariant(contact.identityImage.large) + of ContactRoles.RequestReceived: result = newQVariant(contact.requestReceived()) method roleNames(self: ContactList): Table[int, string] = { @@ -98,13 +110,15 @@ QtObject: ContactRoles.LocalNickname.int:"localNickname", ContactRoles.EnsVerified.int:"ensVerified", ContactRoles.ThumbnailImage.int:"thumbnailImage", - ContactRoles.LargeImage.int:"largeImage" + ContactRoles.LargeImage.int:"largeImage", + ContactRoles.RequestReceived.int:"requestReceived" }.toTable proc addContactToList*(self: ContactList, contact: Profile) = self.beginInsertRows(newQModelIndex(), self.contacts.len, self.contacts.len) self.contacts.add(contact) self.endInsertRows() + self.countChanged() proc hasAddedContacts(self: ContactList): bool {.slot.} = for c in self.contacts: @@ -134,3 +148,4 @@ QtObject: self.beginResetModel() self.contacts = contactList self.endResetModel() + self.countChanged() diff --git a/src/app/profile/views/contacts.nim b/src/app/profile/views/contacts.nim index 8a2fdeea99..d02ed363db 100644 --- a/src/app/profile/views/contacts.nim +++ b/src/app/profile/views/contacts.nim @@ -1,4 +1,4 @@ -import NimQml, chronicles, sequtils, sugar, strutils +import NimQml, chronicles, sequtils, sugar, strutils, json import ../../../status/libstatus/utils as status_utils import ../../../status/status import ../../../status/chat/chat @@ -34,6 +34,7 @@ QtObject: type ContactsView* = ref object of QObject status: Status contactList*: ContactList + contactRequests*: ContactList addedContacts*: ContactList blockedContacts*: ContactList contactToAdd*: Profile @@ -44,6 +45,7 @@ QtObject: proc delete*(self: ContactsView) = self.contactList.delete self.addedContacts.delete + self.contactRequests.delete self.blockedContacts.delete self.QObject.delete @@ -51,6 +53,7 @@ QtObject: new(result, delete) result.status = status result.contactList = newContactList() + result.contactRequests = newContactList() result.addedContacts = newContactList() result.blockedContacts = newContactList() result.contactToAdd = Profile( @@ -63,10 +66,12 @@ QtObject: proc updateContactList*(self: ContactsView, contacts: seq[Profile]) = for contact in contacts: self.contactList.updateContact(contact) - if contact.systemTags.contains(":contact/added"): - self.addedContacts.updateContact(contact) - if contact.systemTags.contains(":contact/blocked"): - self.blockedContacts.updateContact(contact) + if contact.systemTags.contains(contactAdded): + self.addedContacts.updateContact(contact) + if contact.systemTags.contains(contactBlocked): + self.blockedContacts.updateContact(contact) + if contact.systemTags.contains(contactRequest) and not contact.systemTags.contains(contactAdded) and not contact.systemTags.contains(contactBlocked): + self.contactRequests.updateContact(contact) proc contactListChanged*(self: ContactsView) {.signal.} @@ -75,10 +80,18 @@ QtObject: proc setContactList*(self: ContactsView, contactList: seq[Profile]) = self.contactList.setNewData(contactList) - self.addedContacts.setNewData(contactList.filter(c => c.systemTags.contains(":contact/added"))) - self.blockedContacts.setNewData(contactList.filter(c => c.systemTags.contains(":contact/blocked"))) + self.addedContacts.setNewData(contactList.filter(c => c.systemTags.contains(contactAdded))) + self.blockedContacts.setNewData(contactList.filter(c => c.systemTags.contains(contactBlocked))) + self.contactRequests.setNewData(contactList.filter(c => c.systemTags.contains(contactRequest) and not c.systemTags.contains(contactAdded) and not c.systemTags.contains(contactBlocked))) self.contactListChanged() + proc contactRequestAdded*(self: ContactsView, name: string, address: string) {.signal.} + + proc notifyOnNewContactRequests*(self: ContactsView, contacts: seq[Profile]) = + for contact in contacts: + if contact.systemTags.contains(contactRequest) and not contact.systemTags.contains(contactAdded) and not contact.systemTags.contains(contactBlocked): + self.contactRequestAdded(status_ens.userNameOrAlias(contact), contact.address) + QtProperty[QVariant] list: read = getContactList write = setContactList @@ -104,6 +117,13 @@ QtObject: return true return false + proc getContactRequests(self: ContactsView): QVariant {.slot.} = + return newQVariant(self.contactRequests) + + QtProperty[QVariant] contactRequests: + read = getContactRequests + notify = contactListChanged + proc contactToAddChanged*(self: ContactsView) {.signal.} proc getContactToAddUsername(self: ContactsView): QVariant {.slot.} = @@ -129,6 +149,10 @@ QtObject: if id == "": return false self.status.contacts.isAdded(id) + proc contactRequestReceived*(self: ContactsView, id: string): bool {.slot.} = + if id == "": return false + self.status.contacts.contactRequestReceived(id) + proc lookupContact*(self: ContactsView, value: string) {.slot.} = if value == "": return @@ -164,6 +188,19 @@ QtObject: self.status.chat.join(status_utils.getTimelineChatId(publicKey), ChatType.Profile, "", publicKey) self.contactChanged(publicKey, true) + proc rejectContactRequest*(self: ContactsView, publicKey: string) {.slot.} = + self.status.contacts.rejectContactRequest(publicKey) + + proc rejectContactRequests*(self: ContactsView, publicKeysJSON: string) {.slot.} = + let publicKeys = publicKeysJSON.parseJson + for pubkey in publicKeys: + self.rejectContactRequest(pubkey.getStr) + + proc acceptContactRequests*(self: ContactsView, publicKeysJSON: string) {.slot.} = + let publicKeys = publicKeysJSON.parseJson + for pubkey in publicKeys: + discard self.addContact(pubkey.getStr) + proc changeContactNickname*(self: ContactsView, publicKey: string, nickname: string) {.slot.} = var nicknameToSet = nickname if (nicknameToSet == ""): diff --git a/src/status/contacts.nim b/src/status/contacts.nim index 698e259cd2..ac61156d02 100644 --- a/src/status/contacts.nim +++ b/src/status/contacts.nim @@ -1,4 +1,4 @@ -import json, sequtils, sugar +import json, sequtils, sugar, chronicles import libstatus/contacts as status_contacts import libstatus/accounts as status_accounts import libstatus/chat as status_chat @@ -33,13 +33,17 @@ proc getContactByID*(self: ContactModel, id: string): Profile = proc blockContact*(self: ContactModel, id: string): string = var contact = self.getContactByID(id) - contact.systemTags.add(":contact/blocked") + contact.systemTags.add(contactBlocked) + let index = contact.systemTags.find(contactAdded) + if (index > -1): + contact.systemTags.delete(index) discard status_contacts.blockContact(contact) self.events.emit("contactBlocked", Args()) + proc unblockContact*(self: ContactModel, id: string): string = var contact = self.getContactByID(id) - contact.systemTags.delete(contact.systemTags.find(":contact/blocked")) + contact.systemTags.delete(contact.systemTags.find(contactBlocked)) discard status_contacts.saveContact(contact.id, contact.ensVerified, contact.ensName, contact.alias, contact.identicon, contact.identityImage.thumbnail, contact.systemTags, contact.localNickname) self.events.emit("contactUnblocked", Args()) @@ -47,7 +51,7 @@ proc getAllContacts*(): seq[Profile] = result = map(status_contacts.getContacts().getElems(), proc(x: JsonNode): Profile = x.toProfileModel()) proc getAddedContacts*(): seq[Profile] = - result = getAllContacts().filter(c => c.systemTags.contains(":contact/added")) + result = getAllContacts().filter(c => c.systemTags.contains(contactAdded)) proc getContacts*(self: ContactModel): seq[Profile] = result = getAllContacts() @@ -89,10 +93,16 @@ proc setNickName*(self: ContactModel, id: string, localNickname: string): string proc addContact*(self: ContactModel, id: string): string = var contact = self.getOrCreateContact(id) - let updating = contact.systemTags.contains(":contact/added") + + let updating = contact.systemTags.contains(contactAdded) if not updating: - contact.systemTags.add(":contact/added") + contact.systemTags.add(contactAdded) discard status_chat.createProfileChat(contact.id) + else: + let index = contact.systemTags.find(contactBlocked) + if (index > -1): + contact.systemTags.delete(index) + var thumbnail = "" if contact.identityImage != nil: thumbnail = contact.identityImage.thumbnail @@ -117,7 +127,7 @@ proc addContact*(self: ContactModel, id: string): string = proc removeContact*(self: ContactModel, id: string) = let contact = self.getContactByID(id) - contact.systemTags.delete(contact.systemTags.find(":contact/added")) + contact.systemTags.delete(contact.systemTags.find(contactAdded)) var thumbnail = "" if contact.identityImage != nil: @@ -129,4 +139,20 @@ proc removeContact*(self: ContactModel, id: string) = proc isAdded*(self: ContactModel, id: string): bool = var contact = self.getContactByID(id) if contact.isNil: return false - contact.systemTags.contains(":contact/added") + contact.systemTags.contains(contactAdded) + +proc contactRequestReceived*(self: ContactModel, id: string): bool = + var contact = self.getContactByID(id) + if contact.isNil: return false + contact.systemTags.contains(contactRequest) + +proc rejectContactRequest*(self: ContactModel, id: string) = + let contact = self.getContactByID(id) + contact.systemTags.delete(contact.systemTags.find(contactRequest)) + + var thumbnail = "" + if contact.identityImage != nil: + thumbnail = contact.identityImage.thumbnail + + discard status_contacts.saveContact(contact.id, contact.ensVerified, contact.ensName, contact.alias, contact.identicon, thumbnail, contact.systemTags, contact.localNickname) + self.events.emit("contactRemoved", Args()) diff --git a/src/status/profile/profile.nim b/src/status/profile/profile.nim index bac361df1c..81e3d73a19 100644 --- a/src/status/profile/profile.nim +++ b/src/status/profile/profile.nim @@ -8,11 +8,19 @@ type Profile* = ref object appearance*: int systemTags*: seq[string] + +const contactAdded* = ":contact/added" +const contactBlocked* = ":contact/blocked" +const contactRequest* = ":contact/request-received" + proc isContact*(self: Profile): bool = - result = self.systemTags.contains(":contact/added") and not self.systemTags.contains(":contact/blocked") + result = self.systemTags.contains(contactAdded) and not self.systemTags.contains(":contact/blocked") proc isBlocked*(self: Profile): bool = - result = self.systemTags.contains(":contact/blocked") + result = self.systemTags.contains(contactBlocked) + +proc requestReceived*(self: Profile): bool = + result = self.systemTags.contains(contactRequest) proc toProfileModel*(account: Account): Profile = result = Profile( diff --git a/ui/app/AppLayouts/Chat/ChatColumn.qml b/ui/app/AppLayouts/Chat/ChatColumn.qml index 8ee8079ac9..24d1175f31 100644 --- a/ui/app/AppLayouts/Chat/ChatColumn.qml +++ b/ui/app/AppLayouts/Chat/ChatColumn.qml @@ -29,13 +29,17 @@ StackLayout { property var doNotShowAddToContactBannerToThose: ([]) property var onActivated: function () { - chatInput.textInput.forceActiveFocus(Qt.MouseFocusReason) + inputArea.chatInput.textInput.forceActiveFocus(Qt.MouseFocusReason) } property string activeChatId: chatsModel.activeChannel.id property bool isBlocked: profileModel.contacts.isContactBlocked(activeChatId) + property bool isContact: profileModel.contacts.isAdded(activeChatId) - property alias input: chatInput + property alias input: inputArea.chatInput + + property string currentNotificationChatId + property string currentNotificationCommunityId property string hoveredMessage property string activeMessage @@ -57,7 +61,7 @@ StackLayout { } Component.onCompleted: { - chatInput.textInput.forceActiveFocus(Qt.MouseFocusReason) + inputArea.chatInput.textInput.forceActiveFocus(Qt.MouseFocusReason) } Layout.fillHeight: true @@ -95,12 +99,12 @@ StackLayout { identicon: chatsModel.messageList.getMessageData(i, "identicon"), localNickname: chatsModel.messageList.getMessageData(i, "localName") }) - chatInput.suggestionsList.append(suggestionsObj[suggestionsObj.length - 1]); + inputArea.chatInput.suggestionsList.append(suggestionsObj[suggestionsObj.length - 1]); idMap[contactAddr] = true; } function populateSuggestions() { - chatInput.suggestionsList.clear() + inputArea.chatInput.suggestionsList.clear() const len = chatsModel.suggestionList.rowCount() idMap = {} @@ -116,7 +120,7 @@ StackLayout { localNickname: chatsModel.suggestionList.rowData(i, "localNickname") }) - chatInput.suggestionsList.append(suggestionsObj[suggestionsObj.length - 1]); + inputArea.chatInput.suggestionsList.append(suggestionsObj[suggestionsObj.length - 1]); idMap[contactAddr] = true; } const len2 = chatsModel.messageList.rowCount(); @@ -135,7 +139,7 @@ StackLayout { let message = chatsModel.messageList.getMessageData(replyMessageIndex, "message") let identicon = chatsModel.messageList.getMessageData(replyMessageIndex, "identicon") - chatInput.showReplyArea(userName, message, identicon) + inputArea.chatInput.showReplyArea(userName, message, identicon) } function requestAddressForTransaction(address, amount, tokenAddress, tokenDecimals = 18) { @@ -165,6 +169,25 @@ StackLayout { } } + function clickOnNotification() { + applicationWindow.show() + applicationWindow.raise() + applicationWindow.requestActivate() + appMain.changeAppSection(Constants.chat) + if (currentNotificationChatId) { + chatsModel.setActiveChannel(currentNotificationChatId) + } else if (currentNotificationCommunityId) { + chatsModel.communities.setActiveCommunity(currentNotificationCommunityId) + } + } + + Connections { + target: systemTray + onMessageClicked: function () { + clickOnNotification() + } + } + Timer { id: timer } @@ -222,54 +245,9 @@ StackLayout { } } - Item { - visible: chatsModel.activeChannel.chatType === Constants.chatTypeOneToOne && - !profileModel.contacts.isAdded(activeChatId) && - !doNotShowAddToContactBannerToThose.includes(activeChatId) + AddToContactBanner { Layout.alignment: Qt.AlignTop Layout.fillWidth: true - height: 36 - - SVGImage { - source: "../../img/plusSign.svg" - anchors.right: addToContactsTxt.left - anchors.rightMargin: Style.current.smallPadding - anchors.verticalCenter: parent.verticalCenter - layer.enabled: true - layer.effect: ColorOverlay { color: addToContactsTxt.color } - } - - StyledText { - id: addToContactsTxt - text: qsTr("Add to contacts") - color: Style.current.primary - anchors.centerIn: parent - } - - Separator { - anchors.bottom: parent.bottom - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: profileModel.contacts.addContact(activeChatId) - } - - StatusIconButton { - id: closeBtn - icon.name: "close" - onClicked: { - const newArray = Object.assign([], doNotShowAddToContactBannerToThose) - newArray.push(activeChatId) - doNotShowAddToContactBannerToThose = newArray - } - width: 20 - height: 20 - anchors.right: parent.right - anchors.rightMargin: Style.current.halfPadding - anchors.verticalCenter: parent.verticalCenter - } } StackLayout { @@ -306,8 +284,8 @@ StackLayout { Connections { target: chatsModel onActiveChannelChanged: { - chatInput.suggestions.hide(); - chatInput.textInput.forceActiveFocus(Qt.MouseFocusReason) + inputArea.chatInput.suggestions.hide(); + inputArea.chatInput.textInput.forceActiveFocus(Qt.MouseFocusReason) populateSuggestions(); } onMessagePushed: { @@ -322,97 +300,17 @@ StackLayout { } } - Rectangle { + ChatRequestMessage { + Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom + Layout.fillWidth: true + Layout.bottomMargin: Style.current.bigPadding + } + + InputArea { id: inputArea Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom Layout.fillWidth: true Layout.preferredWidth: parent.width - height: chatInput.height - Layout.preferredHeight: height - color: "transparent" - - Connections { - target: chatsModel - onLoadingMessagesChanged: - if(value){ - loadingMessagesIndicator.active = true - } else { - timer.setTimeout(function(){ - loadingMessagesIndicator.active = false; - }, 5000); - } - } - - Loader { - id: loadingMessagesIndicator - active: chatsModel.loadingMessages - sourceComponent: loadingIndicator - anchors.right: parent.right - anchors.bottom: chatInput.top - anchors.rightMargin: Style.current.padding - anchors.bottomMargin: Style.current.padding - } - - Component { - id: loadingIndicator - LoadingAnimation {} - } - - StatusChatInput { - id: chatInput - visible: { - const community = chatsModel.communities.activeCommunity - if (chatsModel.activeChannel.chatType === Constants.chatTypePrivateGroupChat) { - return chatsModel.activeChannel.isMember - } - return !community.active || - community.access === Constants.communityChatPublicAccess || - community.admin || - chatsModel.activeChannel.canPost - } - enabled: !isBlocked - chatInputPlaceholder: isBlocked ? - //% "This user has been blocked." - qsTrId("this-user-has-been-blocked-") : - //% "Type a message." - qsTrId("type-a-message-") - anchors.bottom: parent.bottom - recentStickers: chatsModel.stickers.recent - stickerPackList: chatsModel.stickers.stickerPacks - chatType: chatsModel.activeChannel.chatType - onSendTransactionCommandButtonClicked: { - if (chatsModel.activeChannel.ensVerified) { - txModalLoader.sourceComponent = cmpSendTransactionWithEns - } else { - txModalLoader.sourceComponent = cmpSendTransactionNoEns - } - txModalLoader.item.open() - } - onReceiveTransactionCommandButtonClicked: { - txModalLoader.sourceComponent = cmpReceiveTransaction - txModalLoader.item.open() - } - onStickerSelected: { - chatsModel.stickers.send(hashId, packId) - } - onSendMessage: { - if (chatInput.fileUrls.length > 0){ - chatsModel.sendImages(JSON.stringify(fileUrls)); - } - let msg = chatsModel.plainText(Emoji.deparse(chatInput.textInput.text)) - if (msg.length > 0){ - msg = chatInput.interpretMessage(msg) - chatsModel.sendMessage(msg, chatInput.isReply ? SelectedMessage.messageId : "", Utils.isOnlyEmoji(msg) ? Constants.emojiType : Constants.messageType, false, JSON.stringify(suggestionsObj)); - if(event) event.accepted = true - sendMessageSound.stop(); - Qt.callLater(sendMessageSound.play); - - chatInput.textInput.clear(); - chatInput.textInput.textFormat = TextEdit.PlainText; - chatInput.textInput.textFormat = TextEdit.RichText; - } - } - } } } diff --git a/ui/app/AppLayouts/Chat/ChatColumn/ChatComponents/AddToContactBanner.qml b/ui/app/AppLayouts/Chat/ChatColumn/ChatComponents/AddToContactBanner.qml new file mode 100644 index 0000000000..fbaaaee174 --- /dev/null +++ b/ui/app/AppLayouts/Chat/ChatColumn/ChatComponents/AddToContactBanner.qml @@ -0,0 +1,53 @@ +import QtQuick 2.13 +import QtGraphicalEffects 1.13 +import "../../../../../imports" +import "../../../../../shared" +import "../../../../../shared/status" + +Item { + visible: chatsModel.activeChannel.chatType === Constants.chatTypeOneToOne && + !isContact && + !doNotShowAddToContactBannerToThose.includes(activeChatId) + height: 36 + + SVGImage { + source: "../../../../img/plusSign.svg" + anchors.right: addToContactsTxt.left + anchors.rightMargin: Style.current.smallPadding + anchors.verticalCenter: parent.verticalCenter + layer.enabled: true + layer.effect: ColorOverlay { color: addToContactsTxt.color } + } + + StyledText { + id: addToContactsTxt + text: qsTr("Add to contacts") + color: Style.current.primary + anchors.centerIn: parent + } + + Separator { + anchors.bottom: parent.bottom + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: profileModel.contacts.addContact(activeChatId) + } + + StatusIconButton { + id: closeBtn + icon.name: "close" + onClicked: { + const newArray = Object.assign([], doNotShowAddToContactBannerToThose) + newArray.push(activeChatId) + doNotShowAddToContactBannerToThose = newArray + } + width: 20 + height: 20 + anchors.right: parent.right + anchors.rightMargin: Style.current.halfPadding + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/ui/app/AppLayouts/Chat/ChatColumn/ChatComponents/ChatRequestMessage.qml b/ui/app/AppLayouts/Chat/ChatColumn/ChatComponents/ChatRequestMessage.qml new file mode 100644 index 0000000000..a5b9219df0 --- /dev/null +++ b/ui/app/AppLayouts/Chat/ChatColumn/ChatComponents/ChatRequestMessage.qml @@ -0,0 +1,48 @@ +import QtQuick 2.13 +import "../../../../../imports" +import "../../../../../shared" +import "../../../../../shared/status" + +Item { + visible: chatsModel.activeChannel.chatType === Constants.chatTypeOneToOne && !isContact + width: parent.width + height: childrenRect.height + + Image { + id: waveImg + source: "../../../../img/wave.png" + width: 80 + height: 80 + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + id: contactText1 + text: qsTr("You need to be mutual contacts with this person for them to receive your messages") + anchors.top: waveImg.bottom + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + anchors.topMargin: Style.current.padding + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width / 1.3 + } + + StyledText { + id: contactText2 + text: qsTr("Just click this button to add them as contact. They will receive a notification all once they accept you as contact as well, you'll be able to chat") + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + anchors.top: contactText1.bottom + anchors.topMargin: 2 + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width / 1.3 + } + + StatusButton { + text: qsTr("Add to contacts") + anchors.top: contactText2.bottom + anchors.topMargin: Style.current.smallPadding + anchors.horizontalCenter: parent.horizontalCenter + onClicked: profileModel.contacts.addContact(activeChatId) + } +} diff --git a/ui/app/AppLayouts/Chat/ChatColumn/ChatComponents/InputArea.qml b/ui/app/AppLayouts/Chat/ChatColumn/ChatComponents/InputArea.qml new file mode 100644 index 0000000000..3f9714487c --- /dev/null +++ b/ui/app/AppLayouts/Chat/ChatColumn/ChatComponents/InputArea.qml @@ -0,0 +1,95 @@ +import QtQuick 2.13 +import "../../../../../imports" +import "../../../../../shared" +import "../../../../../shared/status" +import "../../components" + +Item { + property alias chatInput: chatInput + + id: inputArea + height: chatInput.height + + Connections { + target: chatsModel + onLoadingMessagesChanged: + if(value){ + loadingMessagesIndicator.active = true + } else { + timer.setTimeout(function(){ + loadingMessagesIndicator.active = false; + }, 5000); + } + } + + Loader { + id: loadingMessagesIndicator + active: chatsModel.loadingMessages + sourceComponent: loadingIndicator + anchors.right: parent.right + anchors.bottom: chatInput.top + anchors.rightMargin: Style.current.padding + anchors.bottomMargin: Style.current.padding + } + + Component { + id: loadingIndicator + LoadingAnimation {} + } + + StatusChatInput { + id: chatInput + visible: { + const community = chatsModel.communities.activeCommunity + if (chatsModel.activeChannel.chatType === Constants.chatTypePrivateGroupChat) { + return chatsModel.activeChannel.isMember + } + return !community.active || + community.access === Constants.communityChatPublicAccess || + community.admin || + chatsModel.activeChannel.canPost + } + enabled: !isBlocked + chatInputPlaceholder: isBlocked ? + //% "This user has been blocked." + qsTrId("this-user-has-been-blocked-") : + //% "Type a message." + qsTrId("type-a-message-") + anchors.bottom: parent.bottom + recentStickers: chatsModel.stickers.recent + stickerPackList: chatsModel.stickers.stickerPacks + chatType: chatsModel.activeChannel.chatType + onSendTransactionCommandButtonClicked: { + if (chatsModel.activeChannel.ensVerified) { + txModalLoader.sourceComponent = cmpSendTransactionWithEns + } else { + txModalLoader.sourceComponent = cmpSendTransactionNoEns + } + txModalLoader.item.open() + } + onReceiveTransactionCommandButtonClicked: { + txModalLoader.sourceComponent = cmpReceiveTransaction + txModalLoader.item.open() + } + onStickerSelected: { + chatsModel.stickers.send(hashId, packId) + } + onSendMessage: { + if (chatInput.fileUrls.length > 0){ + chatsModel.sendImages(JSON.stringify(fileUrls)); + } + let msg = chatsModel.plainText(Emoji.deparse(chatInput.textInput.text)) + if (msg.length > 0){ + msg = chatInput.interpretMessage(msg) + chatsModel.sendMessage(msg, chatInput.isReply ? SelectedMessage.messageId : "", Utils.isOnlyEmoji(msg) ? Constants.emojiType : Constants.messageType, false, JSON.stringify(suggestionsObj)); + if(event) event.accepted = true + sendMessageSound.stop(); + Qt.callLater(sendMessageSound.play); + + chatInput.textInput.clear(); + chatInput.textInput.textFormat = TextEdit.PlainText; + chatInput.textInput.textFormat = TextEdit.RichText; + } + } + } +} diff --git a/ui/app/AppLayouts/Chat/ChatColumn/ChatMessages.qml b/ui/app/AppLayouts/Chat/ChatColumn/ChatMessages.qml index 7b07741bd0..a5647f67bb 100644 --- a/ui/app/AppLayouts/Chat/ChatColumn/ChatMessages.qml +++ b/ui/app/AppLayouts/Chat/ChatColumn/ChatMessages.qml @@ -7,6 +7,7 @@ import QtQml.Models 2.13 import QtGraphicalEffects 1.13 import QtQuick.Dialogs 1.3 import "../../../../shared" +import "../../../../shared/status" import "../../../../imports" import "../components" import "./samples/" @@ -31,8 +32,6 @@ ScrollView { ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ListView { - property string currentNotificationChatId - id: chatLogView anchors.fill: parent anchors.bottomMargin: Style.current.bigPadding @@ -47,23 +46,33 @@ ScrollView { verticalLayoutDirection: ListView.BottomToTop // This header and Connections is to create an invisible padding so that the chat identifier is at the top - // The Connections is necessary, because doing the check inside teh ehader created a binding loop (the contentHeight includes the header height + // The Connections is necessary, because doing the check inside the header created a binding loop (the contentHeight includes the header height // If the content height is smaller than the full height, we "show" the padding so that the chat identifier is at the top, otherwise we disable the Connections header: Item { height: 0 width: chatLogView.width } + function checkHeaderHeight() { + if (!chatLogView.headerItem) { + return + } + + if (chatLogView.contentItem.height - chatLogView.headerItem.height < chatLogView.height) { + chatLogView.headerItem.height = chatLogView.height - (chatLogView.contentItem.height - chatLogView.headerItem.height) - 36 + } else { + chatLogView.headerItem.height = 0 + } + } + Connections { id: contentHeightConnection enabled: true target: chatLogView onContentHeightChanged: { - if (chatLogView.contentItem.height - chatLogView.headerItem.height < chatLogView.height) { - chatLogView.headerItem.height = chatLogView.height - (chatLogView.contentItem.height - chatLogView.headerItem.height) - 36 - } else { - chatLogView.headerItem.height = 0 - contentHeightConnection.enabled = false - } + chatLogView.checkHeaderHeight() + } + onHeightChanged: { + chatLogView.checkHeaderHeight() } } @@ -148,14 +157,6 @@ ScrollView { return true } - function clickOnNotification(chatId) { - applicationWindow.show() - applicationWindow.raise() - applicationWindow.requestActivate() - chatsModel.setActiveChannel(chatId) - appMain.changeAppSection(Constants.chat) - } - Connections { target: chatsModel onMessagesLoaded: { @@ -191,7 +192,8 @@ ScrollView { return } - chatLogView.currentNotificationChatId = chatId + chatColumnLayout.currentNotificationChatId = chatId + chatColumnLayout.currentNotificationCommunityId = null let name; if (appSettings.notificationMessagePreviewSetting === Constants.notificationPreviewAnonymous) { @@ -223,7 +225,7 @@ ScrollView { SystemTrayIcon.NoIcon, Constants.notificationPopupTTL) } else { - notificationWindow.notifyUser(chatId, name, message, chatType, identicon, chatLogView.clickOnNotification) + notificationWindow.notifyUser(chatId, name, message, chatType, identicon, chatColumnLayout.clickOnNotification) } } } @@ -232,7 +234,9 @@ ScrollView { Connections { target: chatsModel.communities - onMembershipRequestChanged: function (communityName, accepted) { + onMembershipRequestChanged: function (communityId, communityName, accepted) { + chatColumnLayout.currentNotificationChatId = null + chatColumnLayout.currentNotificationCommunityId = communityId systemTray.showMessage("Status", accepted ? qsTr("You have been accepted into the ‘%1’ community").arg(communityName) : qsTr("Your request to join the ‘%1’ community was declined").arg(communityName), @@ -240,7 +244,9 @@ ScrollView { Constants.notificationPopupTTL) } - onMembershipRequestPushed: function (communityName, pubKey) { + onMembershipRequestPushed: function (communityId, communityName, pubKey) { + chatColumnLayout.currentNotificationChatId = null + chatColumnLayout.currentNotificationCommunityId = communityId systemTray.showMessage(qsTr("New membership request"), qsTr("%1 asks to join ‘%2’").arg(Utils.getDisplayName(pubKey)).arg(communityName), SystemTrayIcon.NoIcon, @@ -248,13 +254,6 @@ ScrollView { } } - Connections { - target: systemTray - onMessageClicked: { - chatLogView.clickOnNotification(chatLogView.currentNotificationChatId) - } - } - property var loadMsgs : Backpressure.oneInTime(chatLogView, 500, function() { if(loadingMessages) return; loadingMessages = true; diff --git a/ui/app/AppLayouts/Chat/ContactsColumn.qml b/ui/app/AppLayouts/Chat/ContactsColumn.qml index 116c507879..1e0990736c 100644 --- a/ui/app/AppLayouts/Chat/ContactsColumn.qml +++ b/ui/app/AppLayouts/Chat/ContactsColumn.qml @@ -1,9 +1,11 @@ import QtQuick 2.13 +import Qt.labs.platform 1.1 import QtQuick.Controls 2.13 import QtQuick.Layouts 1.13 import "../../../imports" import "../../../shared" +import "../../../shared/status" import "./components" import "./ContactsColumn" import "./CommunityComponents" @@ -90,6 +92,15 @@ Rectangle { } } + Component { + id: contactRequestsPopup + ContactRequestsPopup { + onClosed: { + destroy() + } + } + } + SearchBox { id: searchBox anchors.top: title.bottom @@ -108,9 +119,37 @@ Rectangle { anchors.topMargin: Style.current.padding } + Connections { + target: profileModel.contacts + onContactRequestAdded: { + systemTray.showMessage(qsTr("New contact request"), + qsTr("%1 requests to become contacts").arg(Utils.removeStatusEns(name)), + SystemTrayIcon.NoIcon, + Constants.notificationPopupTTL) + } + } + + StatusSettingsLineButton { + property int nbRequests: profileModel.contacts.contactRequests.count + + id: contactRequest + anchors.top: searchBox.bottom + anchors.topMargin: visible ? Style.current.padding : 0 + anchors.left: parent.left + anchors.leftMargin: Style.current.halfPadding + anchors.right: parent.right + anchors.rightMargin: Style.current.halfPadding + visible: nbRequests > 0 + height: visible ? implicitHeight : 0 + text: qsTr("Contact requests") + isBadge: true + badgeText: nbRequests.toString() + onClicked: openPopup(contactRequestsPopup) + } + ScrollView { id: chatGroupsContainer - anchors.top: searchBox.bottom + anchors.top: contactRequest.bottom anchors.topMargin: Style.current.padding anchors.bottom: parent.bottom anchors.left: parent.left diff --git a/ui/app/AppLayouts/Chat/components/ContactRequestsPopup.qml b/ui/app/AppLayouts/Chat/components/ContactRequestsPopup.qml new file mode 100644 index 0000000000..2365de5c58 --- /dev/null +++ b/ui/app/AppLayouts/Chat/components/ContactRequestsPopup.qml @@ -0,0 +1,106 @@ +import QtQuick 2.13 +import QtQuick.Controls 2.13 +import QtQuick.Layouts 1.13 +import QtGraphicalEffects 1.13 +import "../../../../imports" +import "../../../../shared" +import "../../../../shared/status" +import "../../Profile/Sections/Contacts" + +ModalPopup { + id: popup + + + title: qsTr("Contact requests") + + ListView { + id: contactList + + property Component profilePopupComponent: ProfilePopup { + id: profilePopup + onClosed: destroy() + } + + anchors.fill: parent + anchors.leftMargin: -Style.current.halfPadding + anchors.rightMargin: -Style.current.halfPadding + + model: profileModel.contacts.contactRequests + clip: true + + delegate: ContactRequest { + name: Utils.removeStatusEns(model.name) + address: model.address + localNickname: model.localNickname + identicon: model.thumbnailImage || model.identicon + profileClick: function (showFooter, userName, fromAuthor, identicon, textParam, nickName) { + var popup = profilePopupComponent.createObject(contactList); + popup.openPopup(showFooter, userName, fromAuthor, identicon, textParam, nickName); + } + onBlockContactActionTriggered: { + blockContactConfirmationDialog.contactName = name + blockContactConfirmationDialog.contactAddress = address + blockContactConfirmationDialog.open() + } + } + } + + footer: Item { + width: parent.width + height: children[0].height + + BlockContactConfirmationDialog { + id: blockContactConfirmationDialog + onBlockButtonClicked: { + profileModel.contacts.blockContact(blockContactConfirmationDialog.contactAddress) + blockContactConfirmationDialog.close() + } + } + + ConfirmationDialog { + id: declineAllDialog + title: qsTr("Decline all contacts") + confirmationText: qsTr("Are you sure you want to decline all these contact requests") + onConfirmButtonClicked: { + const pubkeys = [] + for (let i = 0; i < contactList.count; i++) { + pubkeys.push(contactList.itemAtIndex(i).address) + } + profileModel.contacts.rejectContactRequests(JSON.stringify(pubkeys)) + declineAllDialog.close() + } + } + + ConfirmationDialog { + id: acceptAllDialog + title: qsTr("Accept all contacts") + confirmationText: qsTr("Are you sure you want to accept all these contact requests") + onConfirmButtonClicked: { + const pubkeys = [] + for (let i = 0; i < contactList.count; i++) { + pubkeys.push(contactList.itemAtIndex(i).address) + } + profileModel.contacts.acceptContactRequests(JSON.stringify(pubkeys)) + acceptAllDialog.close() + } + } + + StatusButton { + id: blockBtn + anchors.right: addToContactsButton.left + anchors.rightMargin: Style.current.padding + anchors.bottom: parent.bottom + type: "warn" + text: qsTr("Decline all") + onClicked: declineAllDialog.open() + } + + StatusButton { + id: addToContactsButton + anchors.right: parent.right + text: qsTr("Accept all") + anchors.bottom: parent.bottom + onClicked: acceptAllDialog.open() + } + } +} diff --git a/ui/app/AppLayouts/Profile/Sections/Contacts/ContactList.qml b/ui/app/AppLayouts/Profile/Sections/Contacts/ContactList.qml index 8cf6bdb0af..50a35b8365 100644 --- a/ui/app/AppLayouts/Profile/Sections/Contacts/ContactList.qml +++ b/ui/app/AppLayouts/Profile/Sections/Contacts/ContactList.qml @@ -61,7 +61,7 @@ ListView { // TODO: Make ConfirmationDialog a dynamic component on a future refactor ConfirmationDialog { id: removeContactConfirmationDialog - title: qsTrId("remove-contact") + title: qsTr("Remove contact") //% "Are you sure you want to remove this contact?" confirmationText: qsTrId("are-you-sure-you-want-to-remove-this-contact-") onConfirmButtonClicked: { diff --git a/ui/app/AppLayouts/Profile/Sections/Contacts/ContactRequest.qml b/ui/app/AppLayouts/Profile/Sections/Contacts/ContactRequest.qml new file mode 100644 index 0000000000..237b26a351 --- /dev/null +++ b/ui/app/AppLayouts/Profile/Sections/Contacts/ContactRequest.qml @@ -0,0 +1,130 @@ +import QtQuick 2.13 +import QtQuick.Controls 2.13 +import QtQuick.Layouts 1.13 +import "../../../../../imports" +import "../../../../../shared" +import "../../../../../shared/status" + +Rectangle { + property string name + property string address + property string identicon + property string localNickname + property var profileClick: function() {} + signal blockContactActionTriggered(name: string, address: string) + property bool isHovered: false + id: container + + height: visible ? 64 : 0 + anchors.right: parent.right + anchors.left: parent.left + border.width: 0 + radius: Style.current.radius + color: isHovered ? Style.current.backgroundHover : Style.current.transparent + + StatusImageIdenticon { + id: accountImage + anchors.left: parent.left + anchors.leftMargin: Style.current.padding + anchors.verticalCenter: parent.verticalCenter + source: identicon + } + + StyledText { + id: usernameText + text: name + elide: Text.ElideRight + font.pixelSize: 17 + anchors.top: accountImage.top + anchors.topMargin: Style.current.smallPadding + anchors.left: accountImage.right + anchors.leftMargin: Style.current.padding + anchors.right: declineBtn.left + anchors.rightMargin: Style.current.padding + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: { + if (mouse.button === Qt.RightButton) { + contactContextMenu.popup() + return + } + } + } + + HoverHandler { + onHoveredChanged: container.isHovered = hovered + } + + StatusIconButton { + id: declineBtn + icon.name: "close" + onClicked: profileModel.contacts.rejectContactRequest(container.address) + width: 32 + height: 32 + padding: 6 + iconColor: Style.current.danger + hoveredIconColor: Style.current.danger + highlightedBackgroundColor: Utils.setColorAlpha(Style.current.danger, 0.1) + anchors.right: acceptBtn.left + anchors.rightMargin: Style.current.halfPadding + anchors.verticalCenter: parent.verticalCenter + } + + StatusIconButton { + id: acceptBtn + icon.name: "check-circle" + onClicked: profileModel.contacts.addContact(container.address) + width: 32 + height: 32 + padding: 6 + iconColor: Style.current.success + hoveredIconColor: Style.current.success + highlightedBackgroundColor: Utils.setColorAlpha(Style.current.success, 0.1) + anchors.right: menuButton.left + anchors.rightMargin: Style.current.halfPadding + anchors.verticalCenter: parent.verticalCenter + } + + StatusContextMenuButton { + property int iconSize: 14 + id: menuButton + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Style.current.padding + MouseArea { + id: mouseArea + cursorShape: Qt.PointingHandCursor + anchors.fill: parent + + onClicked: { + contactContextMenu.popup() + } + + PopupMenu { + id: contactContextMenu + hasArrow: false + Action { + icon.source: "../../../../img/profileActive.svg" + icon.width: menuButton.iconSize + icon.height: menuButton.iconSize + //% "View Profile" + text: qsTrId("view-profile") + onTriggered: profileClick(true, name, address, identicon, "", localNickname) + enabled: true + } + Separator {} + Action { + icon.source: "../../../../img/block-icon.svg" + icon.width: menuButton.iconSize + icon.height: menuButton.iconSize + icon.color: Style.current.danger + text: qsTr("Decline and block") + onTriggered: container.blockContactActionTriggered(name, address) + } + } + } + } +} diff --git a/ui/app/img/wave.png b/ui/app/img/wave.png new file mode 100644 index 0000000000..7f3b4b7b38 Binary files /dev/null and b/ui/app/img/wave.png differ diff --git a/ui/nim-status-client.pro b/ui/nim-status-client.pro index 6e428c0f4f..7d2b5e7438 100644 --- a/ui/nim-status-client.pro +++ b/ui/nim-status-client.pro @@ -96,10 +96,13 @@ DISTFILES += \ app/AppLayouts/Browser/FavoritesBar.qml \ app/AppLayouts/Browser/FavoritesList.qml \ app/AppLayouts/Browser/components/BookmarkButton.qml \ + app/AppLayouts/Chat/ChatColumn/ChatComponents/AddToContactBanner.qml \ app/AppLayouts/Chat/ChatColumn/ChatComponents/ChatCommandButton.qml \ app/AppLayouts/Chat/ChatColumn/ChatComponents/ChatCommandModal.qml \ app/AppLayouts/Chat/ChatColumn/ChatComponents/ChatCommandsPopup.qml \ app/AppLayouts/Chat/ChatColumn/ChatComponents/ChatInputButton.qml \ + app/AppLayouts/Chat/ChatColumn/ChatComponents/ChatRequestMessage.qml \ + app/AppLayouts/Chat/ChatColumn/ChatComponents/InputArea.qml \ app/AppLayouts/Chat/ChatColumn/ChatComponents/RequestModal.qml \ app/AppLayouts/Chat/ChatColumn/ChatComponents/SignTransactionModal.qml \ app/AppLayouts/Chat/ChatColumn/CompactMessage.qml \ @@ -156,6 +159,7 @@ DISTFILES += \ app/AppLayouts/Chat/components/CommunitiesPopup.qml \ app/AppLayouts/Chat/components/CommunityDetailPopup.qml \ app/AppLayouts/Chat/components/ContactList.qml \ + app/AppLayouts/Chat/components/ContactRequestsPopup.qml \ app/AppLayouts/Chat/components/CreateCommunityPopup.qml \ app/AppLayouts/Chat/components/EmojiCategoryButton.qml \ app/AppLayouts/Chat/components/EmojiPopup.qml \ @@ -175,6 +179,7 @@ DISTFILES += \ app/AppLayouts/Profile/LeftTab/components/MenuButton.qml \ app/AppLayouts/Chat/data/EmojiReactions.qml \ app/AppLayouts/Profile/Sections/AppearanceContainer.qml \ + app/AppLayouts/Profile/Sections/Contacts/ContactRequest.qml \ app/AppLayouts/Profile/Sections/NetworksModal.qml \ app/AppLayouts/Profile/Sections/BackupSeedModal.qml \ app/AppLayouts/Profile/Sections/BrowserContainer.qml \ diff --git a/ui/shared/status/StatusChatInfo.qml b/ui/shared/status/StatusChatInfo.qml index 0be7cb504e..418b979a3d 100644 --- a/ui/shared/status/StatusChatInfo.qml +++ b/ui/shared/status/StatusChatInfo.qml @@ -119,19 +119,22 @@ Item { text: { switch(root.realChatType){ //% "Public chat" - case Constants.chatTypePublic: return qsTrId("public-chat") - case Constants.chatTypeOneToOne: return (profileModel.contacts.isAdded(root.chatId) ? - //% "Contact" - qsTrId("chat-is-a-contact") : - //% "Not a contact" - qsTrId("chat-is-not-a-contact")) - case Constants.chatTypePrivateGroupChat: - let cnt = chatsModel.activeChannel.members.rowCount(); - //% "%1 members" - if(cnt > 1) return qsTrId("%1-members").arg(cnt); - //% "1 member" - return qsTrId("1-member"); - default: return "..."; + case Constants.chatTypePublic: return qsTrId("public-chat") + case Constants.chatTypeOneToOne: return (profileModel.contacts.isAdded(root.chatId) ? + profileModel.contacts.contactRequestReceived(root.chatId) ? + //% "Contact" + qsTrId("chat-is-a-contact") : + qsTr("Contact request pending") : + + //% "Not a contact" + qsTrId("chat-is-not-a-contact")) + case Constants.chatTypePrivateGroupChat: + let cnt = chatsModel.activeChannel.members.rowCount(); + //% "%1 members" + if(cnt > 1) return qsTrId("%1-members").arg(cnt); + //% "1 member" + return qsTrId("1-member"); + default: return "..."; } } font.pixelSize: 12