diff --git a/src/app/modules/main/chat_section/controller.nim b/src/app/modules/main/chat_section/controller.nim index 73d6c68a1b..e47580d892 100644 --- a/src/app/modules/main/chat_section/controller.nim +++ b/src/app/modules/main/chat_section/controller.nim @@ -293,6 +293,9 @@ proc addGroupMembers*(self: Controller, chatId: string, pubKeys: seq[string]) = proc removeMemberFromGroupChat*(self: Controller, communityID: string, chatId: string, pubKey: string) = self.chatService.removeMemberFromGroupChat(communityID, chatId, pubKey) +proc removeMembersFromGroupChat*(self: Controller, communityID: string, chatId: string, pubKeys: seq[string]) = + self.chatService.removeMembersFromGroupChat(communityID, chatId, pubKeys) + proc renameGroupChat*(self: Controller, chatId: string, newName: string) = let communityId = if self.isCommunitySection: self.sectionId else: "" self.chatService.renameGroupChat(communityId, chatId, newName) diff --git a/src/app/modules/main/chat_section/io_interface.nim b/src/app/modules/main/chat_section/io_interface.nim index 12e7278ce5..cbaf146824 100644 --- a/src/app/modules/main/chat_section/io_interface.nim +++ b/src/app/modules/main/chat_section/io_interface.nim @@ -205,6 +205,9 @@ method addGroupMembers*(self: AccessInterface, chatId: string, pubKeys: string) method removeMemberFromGroupChat*(self: AccessInterface, communityID: string, chatId: string, pubKey: string) {.base.} = raise newException(ValueError, "No implementation available") +method removeMembersFromGroupChat*(self: AccessInterface, communityID: string, chatId: string, pubKeys: string) {.base.} = + raise newException(ValueError, "No implementation available") + method renameGroupChat*(self: AccessInterface, chatId: string, newName: string) {.base.} = raise newException(ValueError, "No implementation available") diff --git a/src/app/modules/main/chat_section/module.nim b/src/app/modules/main/chat_section/module.nim index a60047416b..8143323ff5 100644 --- a/src/app/modules/main/chat_section/module.nim +++ b/src/app/modules/main/chat_section/module.nim @@ -686,6 +686,9 @@ method addGroupMembers*(self: Module, chatId: string, pubKeys: string) = method removeMemberFromGroupChat*(self: Module, communityID: string, chatId: string, pubKey: string) = self.controller.removeMemberFromGroupChat(communityID, chatId, pubKey) +method removeMembersFromGroupChat*(self: Module, communityID: string, chatId: string, pubKeys: string) = + self.controller.removeMembersFromGroupChat(communityID, chatId, self.convertPubKeysToJson(pubKeys)) + method renameGroupChat*(self: Module, chatId: string, newName: string) = self.controller.renameGroupChat(chatId, newName) diff --git a/src/app/modules/main/chat_section/view.nim b/src/app/modules/main/chat_section/view.nim index 48e4b7db6d..a3d5e2de46 100644 --- a/src/app/modules/main/chat_section/view.nim +++ b/src/app/modules/main/chat_section/view.nim @@ -186,6 +186,9 @@ QtObject: proc removeMemberFromGroupChat*(self: View, communityID: string, chatId: string, pubKey: string) {.slot.} = self.delegate.removeMemberFromGroupChat(communityID, chatId, pubKey) + + proc removeMembersFromGroupChat*(self: View, communityID: string, chatId: string, pubKeys: string) {.slot.} = + self.delegate.removeMembersFromGroupChat(communityID, chatId, pubKeys) proc renameGroupChat*(self: View, chatId: string, newName: string) {.slot.} = self.delegate.renameGroupChat(chatId, newName) diff --git a/src/app_service/service/chat/service.nim b/src/app_service/service/chat/service.nim index beb55de148..84f54af79e 100644 --- a/src/app_service/service/chat/service.nim +++ b/src/app_service/service/chat/service.nim @@ -464,6 +464,12 @@ QtObject: except Exception as e: error "error while removing member from group: ", msg = e.msg + proc removeMembersFromGroupChat*(self: Service, communityID: string, chatID: string, members: seq[string]) = + try: + for member in members: + self.removeMemberFromGroupChat(communityID, chatID, member) + except Exception as e: + error "error while removing members from group: ", msg = e.msg proc renameGroupChat*(self: Service, communityID: string, chatID: string, name: string) = try: diff --git a/ui/StatusQ b/ui/StatusQ index be0f49a8d8..a4d0c13662 160000 --- a/ui/StatusQ +++ b/ui/StatusQ @@ -1 +1 @@ -Subproject commit be0f49a8d80a8489e9f26f765167c3239c0c92c1 +Subproject commit a4d0c1366256d4e552990bde63734b7ceea0ad80 diff --git a/ui/app/AppLayouts/Chat/panels/GroupChatPanel.qml b/ui/app/AppLayouts/Chat/panels/GroupChatPanel.qml new file mode 100644 index 0000000000..7cef0aa2de --- /dev/null +++ b/ui/app/AppLayouts/Chat/panels/GroupChatPanel.qml @@ -0,0 +1,180 @@ +import QtQuick 2.13 +import QtQuick.Layouts 1.13 + +import StatusQ.Components 0.1 +import StatusQ.Controls 0.1 + +import utils 1.0 + + +RowLayout { + id: root + + property var sectionModule + property var chatContentModule + property var rootStore + property int maxHeight + + signal panelClosed() + + QtObject { + id: d + + property ListModel groupUsersModel: ListModel { } + property ListModel contactsModel: ListModel { } + property var addedMembersIds: [] + property var removedMembersIds: [] + + function initialize () { + groupUsersModel.clear() + contactsModel.clear() + addedMembersIds = [] + removedMembersIds = [] + tagSelector.namesModel.clear() + } + } + + ListView { + id: groupUsersModelListView + visible: false + model: root.chatContentModule.usersModule.model + delegate: Item { + property string publicId: model.id + property string name: model.name + property string icon: model.icon + property bool isAdmin: model.isAdmin + } + } + + ListView { + id: contactsModelListView + visible: false + model: root.rootStore.contactsModel + delegate: Item { + property string publicId: model.pubKey + property string name: model.name + property string icon: model.icon + } + } + + clip: true + + Component.onCompleted: { + d.initialize() + + // Build groupUsersModel type from model type (to fit with expected StatusTagSelector format + for (var i = 0; i < groupUsersModelListView.count; i ++) { + var entry = groupUsersModelListView.itemAtIndex(i) + + // Add all group users different than me + if(!entry.isAdmin) { + d.groupUsersModel.insert(d.groupUsersModel.count, + {"publicId": entry.publicId, + "name": entry.name, + "icon": entry.icon}) + } + } + + // Build contactsModel type from model type (to fit with expected StatusTagSelector format + for (var j = 0; j < contactsModelListView.count; j ++) { + var entry2 = contactsModelListView.itemAtIndex(j) + d.contactsModel.insert(d.contactsModel.count, + {"publicId": entry2.publicId, + "name": entry2.name, + "icon": entry2.icon, + "isIdenticon": false, + "onlineStatus": false}) + } + + // Update contacts list used by StatusTagSelector + tagSelector.sortModel(d.contactsModel) + } + + StatusTagSelector { + id: tagSelector + + function memberExists(memberId) { + var exists = false + for (var i = 0; i < groupUsersModelListView.count; i ++) { + var entry = groupUsersModelListView.itemAtIndex(i) + if(entry.publicId === memberId) { + exists = true + break + } + } + return exists + } + + function find(val, array) { + for(var i = 0; i < array.length; i++) { + if(array[i] === val) { + return true + } + } + return false + } + + function addNewMember(memberId) { + if(find(memberId, d.addedMembersIds)) { + return + } + + if(!memberExists(memberId)) { + d.addedMembersIds.push(memberId) + } + + if(memberExists(memberId) && find(memberId, d.removedMembersIds)) { + d.removedMembersIds.pop(memberId) + } + } + + function removeExistingMember(memberId) { + if(find(memberId, d.removedMembersIds)) { + return + } + + if(memberExists(memberId)) { + d.removedMembersIds.push(memberId) + } + + if(!memberExists(memberId) && find(memberId, d.addedMembersIds)) { + d.addedMembersIds.pop(memberId) + } + } + + namesModel: d.groupUsersModel + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop | Qt.AlignLeft + maxHeight: root.maxHeight + nameCountLimit: 20 + showSortedListOnlyWhenText: true + toLabelText: qsTr("To: ") + warningText: qsTr("USER LIMIT REACHED") + onTextChanged: sortModel(d.contactsModel) + onAddMember: addNewMember(memberId) + onRemoveMember: removeExistingMember(memberId) + ringSpecModelGetter: function(pubKey) { + return Utils.getColorHashAsJson(pubKey); + } + compressedKeyGetter: function(pubKey) { + return Utils.getCompressedPk(pubKey); + } + } + + StatusButton { + id: confirmButton + implicitHeight: 44 + Layout.alignment: Qt.AlignTop + text: "Confirm" + onClicked: { + if(root.chatContentModule.chatDetails.id &&((d.addedMembersIds.length > 0) || (d.removedMembersIds.length > 0))) { + // Add request: + root.sectionModule.addGroupMembers(root.chatContentModule.chatDetails.id, JSON.stringify(d.addedMembersIds)) + + // Remove request: + root.sectionModule.removeMembersFromGroupChat("", root.chatContentModule.chatDetails.id, JSON.stringify(d.removedMembersIds)) + } + root.panelClosed() + } + } +} diff --git a/ui/app/AppLayouts/Chat/views/ChatContentView.qml b/ui/app/AppLayouts/Chat/views/ChatContentView.qml index c724f562d6..f59717a7b1 100644 --- a/ui/app/AppLayouts/Chat/views/ChatContentView.qml +++ b/ui/app/AppLayouts/Chat/views/ChatContentView.qml @@ -57,95 +57,120 @@ ColumnLayout { if(chatContentRoot.height > 0) { chatInput.forceInputActiveFocus() } + } + + Keys.onEscapePressed: { topBar.toolbarComponent = statusChatInfoButton } + + // Chat toolbar content option 1: + Component { + id: statusChatInfoButton + + StatusChatInfoButton { + width: Math.min(implicitWidth, parent.width) + title: chatContentModule? chatContentModule.chatDetails.name : "" + subTitle: { + if(!chatContentModule) + return "" + + // In some moment in future this should be part of the backend logic. + // (once we add transaltion on the backend side) + switch (chatContentModule.chatDetails.type) { + case Constants.chatType.oneToOne: + return (chatContentModule.isMyContact(chatContentModule.chatDetails.id) ? + //% "Contact" + qsTrId("chat-is-a-contact") : + //% "Not a contact" + qsTrId("chat-is-not-a-contact")) + case Constants.chatType.publicChat: + //% "Public chat" + return qsTrId("public-chat") + case Constants.chatType.privateGroupChat: + let cnt = chatContentRoot.usersStore.usersModule.model.count + //% "%1 members" + if(cnt > 1) return qsTrId("-1-members").arg(cnt); + //% "1 member" + return qsTrId("1-member"); + case Constants.chatType.communityChat: + return Utils.linkifyAndXSS(chatContentModule.chatDetails.description).trim() + default: + return "" + } + } + image.source: chatContentModule? chatContentModule.chatDetails.icon : "" + ringSettings.ringSpecModel: chatContentModule && chatContentModule.chatDetails.type === Constants.chatType.oneToOne ? + Utils.getColorHashAsJson(chatContentModule.chatDetails.id) : "" + icon.color: chatContentModule? + chatContentModule.chatDetails.type === Constants.chatType.oneToOne ? + Utils.colorForPubkey(chatContentModule.chatDetails.id) + : chatContentModule.chatDetails.color + : "" + icon.emoji: chatContentModule? chatContentModule.chatDetails.emoji : "" + icon.emojiSize: "24x24" + type: chatContentModule? chatContentModule.chatDetails.type : Constants.chatType.unknown + pinnedMessagesCount: chatContentModule? chatContentModule.pinnedMessagesModel.count : 0 + muted: chatContentModule? chatContentModule.chatDetails.muted : false + + onPinnedMessagesCountClicked: { + if(!chatContentModule) { + console.debug("error on open pinned messages - chat content module is not set") + return + } + Global.openPopup(pinnedMessagesPopupComponent, { + store: rootStore, + messageStore: messageStore, + pinnedMessagesModel: chatContentModule.pinnedMessagesModel, + messageToPin: "" + }) + } + onUnmute: { + if(!chatContentModule) { + console.debug("error on unmute chat - chat content module is not set") + return + } + chatContentModule.unmuteChat() + } + + sensor.enabled: { + if(!chatContentModule) + return false + + return chatContentModule.chatDetails.type !== Constants.chatType.publicChat && + chatContentModule.chatDetails.type !== Constants.chatType.communityChat + } + onClicked: { + switch (chatContentModule.chatDetails.type) { + case Constants.chatType.privateGroupChat: + Global.openPopup(groupInfoPopupComponent, { + chatContentModule: chatContentModule, + chatDetails: chatContentModule.chatDetails + }) + break; + case Constants.chatType.oneToOne: + Global.openProfilePopup(chatContentModule.chatDetails.id) + break; + } + } + } + } + + // Chat toolbar content option 2: + Component { + id: contactsSelector + GroupChatPanel { + sectionModule: chatSectionModule + chatContentModule: chatContentRoot.chatContentModule + rootStore: chatContentRoot.rootStore + maxHeight: chatContentRoot.height + + onPanelClosed: topBar.toolbarComponent = statusChatInfoButton + } } StatusChatToolBar { id: topBar + z: parent.z + 1 Layout.fillWidth: true - - chatInfoButton.title: chatContentModule? chatContentModule.chatDetails.name : "" - chatInfoButton.subTitle: { - if(!chatContentModule) - return "" - - // In some moment in future this should be part of the backend logic. - // (once we add transaltion on the backend side) - switch (chatContentModule.chatDetails.type) { - case Constants.chatType.oneToOne: - return (chatContentModule.isMyContact(chatContentModule.chatDetails.id) ? - //% "Contact" - qsTrId("chat-is-a-contact") : - //% "Not a contact" - qsTrId("chat-is-not-a-contact")) - case Constants.chatType.publicChat: - //% "Public chat" - return qsTrId("public-chat") - case Constants.chatType.privateGroupChat: - let cnt = chatContentRoot.usersStore.usersModule.model.count - //% "%1 members" - if(cnt > 1) return qsTrId("-1-members").arg(cnt); - //% "1 member" - return qsTrId("1-member"); - case Constants.chatType.communityChat: - return Utils.linkifyAndXSS(chatContentModule.chatDetails.description).trim() - default: - return "" - } - } - chatInfoButton.image.source: chatContentModule? chatContentModule.chatDetails.icon : "" - chatInfoButton.ringSettings.ringSpecModel: chatContentModule && chatContentModule.chatDetails.type === Constants.chatType.oneToOne ? - Utils.getColorHashAsJson(chatContentModule.chatDetails.id) : "" - chatInfoButton.icon.color: chatContentModule? - chatContentModule.chatDetails.type === Constants.chatType.oneToOne ? - Utils.colorForPubkey(chatContentModule.chatDetails.id) - : chatContentModule.chatDetails.color - : "" - chatInfoButton.icon.emoji: chatContentModule? chatContentModule.chatDetails.emoji : "" - chatInfoButton.icon.emojiSize: "24x24" - chatInfoButton.type: chatContentModule? chatContentModule.chatDetails.type : Constants.chatType.unknown - chatInfoButton.pinnedMessagesCount: chatContentModule? chatContentModule.pinnedMessagesModel.count : 0 - chatInfoButton.muted: chatContentModule? chatContentModule.chatDetails.muted : false - - chatInfoButton.onPinnedMessagesCountClicked: { - if(!chatContentModule) { - console.debug("error on open pinned messages - chat content module is not set") - return - } - Global.openPopup(pinnedMessagesPopupComponent, { - store: rootStore, - messageStore: messageStore, - pinnedMessagesModel: chatContentModule.pinnedMessagesModel, - messageToPin: "" - }) - } - chatInfoButton.onUnmute: { - if(!chatContentModule) { - console.debug("error on unmute chat - chat content module is not set") - return - } - chatContentModule.unmuteChat() - } - - chatInfoButton.sensor.enabled: { - if(!chatContentModule) - return false - - return chatContentModule.chatDetails.type !== Constants.chatType.publicChat && - chatContentModule.chatDetails.type !== Constants.chatType.communityChat - } - chatInfoButton.onClicked: { - switch (chatContentModule.chatDetails.type) { - case Constants.chatType.privateGroupChat: - Global.openPopup(groupInfoPopupComponent, { - chatContentModule: chatContentModule, - chatDetails: chatContentModule.chatDetails - }) - break; - case Constants.chatType.oneToOne: - Global.openProfilePopup(chatContentModule.chatDetails.id) - break; - } - } + toolbarComponent: statusChatInfoButton membersButton.visible: { if(!chatContentModule || chatContentModule.chatDetails.type === Constants.chatType.publicChat) @@ -258,7 +283,7 @@ ColumnLayout { ) } onAddRemoveGroupMember: { - chatContentRoot.rootStore.addRemoveGroupMember(); + topBar.toolbarComponent = contactsSelector } onFetchMoreMessages: { chatContentRoot.rootStore.messageStore.requestMoreMessages(); @@ -298,7 +323,7 @@ ColumnLayout { Connections { target: mainModule onOnlineStatusChanged: { - if (connected == isConnected) return; + if (connected === isConnected) return; isConnected = connected; if(isConnected){ timer.setTimeout(function(){ diff --git a/ui/app/AppLayouts/Chat/views/ChatContextMenuView.qml b/ui/app/AppLayouts/Chat/views/ChatContextMenuView.qml index 3c7dcc93b6..589eef5253 100644 --- a/ui/app/AppLayouts/Chat/views/ChatContextMenuView.qml +++ b/ui/app/AppLayouts/Chat/views/ChatContextMenuView.qml @@ -46,6 +46,8 @@ StatusPopupMenu { signal fetchMoreMessages(int timeFrame) signal addRemoveGroupMember() + width: root.amIChatAdmin && (root.chatType === Constants.chatType.privateGroupChat) ? 207 : implicitWidth + StatusMenuItem { id: viewProfileMenuItem text: { @@ -72,18 +74,12 @@ StatusPopupMenu { } } - // TODO needs to be implemented - // This should open up the ad-hoc group chat creation view - // Design https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=417%3A243810 - // StatusMenuItem { - // text: qsTr("Add / remove from group") - // icon.name: "notification" - // enabled: (root.chatType === Constants.chatType.privateGroupChat && - // amIChatAdmin) - // onTriggered: { - // root.addRemoveGroupMember(); - // } - // } + StatusMenuItem { + text: qsTr("Add / remove from group") + icon.name: "add-to-dm" + enabled: root.amIChatAdmin && (root.chatType === Constants.chatType.privateGroupChat) + onTriggered: { root.addRemoveGroupMember() } + } StatusMenuSeparator { visible: viewProfileMenuItem.enabled diff --git a/ui/app/AppLayouts/Chat/views/CreateChatView.qml b/ui/app/AppLayouts/Chat/views/CreateChatView.qml index 6db1a3dca9..bfe14eccd2 100644 --- a/ui/app/AppLayouts/Chat/views/CreateChatView.qml +++ b/ui/app/AppLayouts/Chat/views/CreateChatView.qml @@ -88,6 +88,7 @@ Page { color: Theme.palette.statusAppLayout.rightPanelBackgroundColor } + // TODO: Could it be replaced to `GroupChatPanel`? header: RowLayout { id: headerRow width: parent.width