feat(GroupChat/Add-Remove-Contacts): Implement "Add / remove from group" button for group chats

Added new option add / remove contacts in dropdown and created navigation to modify the loaded component in the toolbar.

Enabled addition of new members into a group chat by the admin.

Enabled removal of members of a group chat by the admin.

Added into `ChatContentView` components related to chat toolbar:
- `StatusTagSelector` and its corresponding logic
-  Moved `StatusChatInfoButton` from toolbar to content view.

Added `esc` key event to leave the group chat add / remove panel.

Updated `StatusQ` link.

Closes #5522
This commit is contained in:
Noelia 2022-05-13 19:11:02 +02:00 committed by Noelia
parent 78f985e484
commit 7c92d39359
10 changed files with 319 additions and 99 deletions

View File

@ -293,6 +293,9 @@ proc addGroupMembers*(self: Controller, chatId: string, pubKeys: seq[string]) =
proc removeMemberFromGroupChat*(self: Controller, communityID: string, chatId: string, pubKey: string) = proc removeMemberFromGroupChat*(self: Controller, communityID: string, chatId: string, pubKey: string) =
self.chatService.removeMemberFromGroupChat(communityID, chatId, pubKey) 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) = proc renameGroupChat*(self: Controller, chatId: string, newName: string) =
let communityId = if self.isCommunitySection: self.sectionId else: "" let communityId = if self.isCommunitySection: self.sectionId else: ""
self.chatService.renameGroupChat(communityId, chatId, newName) self.chatService.renameGroupChat(communityId, chatId, newName)

View File

@ -205,6 +205,9 @@ method addGroupMembers*(self: AccessInterface, chatId: string, pubKeys: string)
method removeMemberFromGroupChat*(self: AccessInterface, communityID: string, chatId: string, pubKey: string) {.base.} = method removeMemberFromGroupChat*(self: AccessInterface, communityID: string, chatId: string, pubKey: string) {.base.} =
raise newException(ValueError, "No implementation available") 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.} = method renameGroupChat*(self: AccessInterface, chatId: string, newName: string) {.base.} =
raise newException(ValueError, "No implementation available") raise newException(ValueError, "No implementation available")

View File

@ -686,6 +686,9 @@ method addGroupMembers*(self: Module, chatId: string, pubKeys: string) =
method removeMemberFromGroupChat*(self: Module, communityID: string, chatId: string, pubKey: string) = method removeMemberFromGroupChat*(self: Module, communityID: string, chatId: string, pubKey: string) =
self.controller.removeMemberFromGroupChat(communityID, chatId, pubKey) 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) = method renameGroupChat*(self: Module, chatId: string, newName: string) =
self.controller.renameGroupChat(chatId, newName) self.controller.renameGroupChat(chatId, newName)

View File

@ -186,6 +186,9 @@ QtObject:
proc removeMemberFromGroupChat*(self: View, communityID: string, chatId: string, pubKey: string) {.slot.} = proc removeMemberFromGroupChat*(self: View, communityID: string, chatId: string, pubKey: string) {.slot.} =
self.delegate.removeMemberFromGroupChat(communityID, chatId, pubKey) 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.} = proc renameGroupChat*(self: View, chatId: string, newName: string) {.slot.} =
self.delegate.renameGroupChat(chatId, newName) self.delegate.renameGroupChat(chatId, newName)

View File

@ -464,6 +464,12 @@ QtObject:
except Exception as e: except Exception as e:
error "error while removing member from group: ", msg = e.msg 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) = proc renameGroupChat*(self: Service, communityID: string, chatID: string, name: string) =
try: try:

@ -1 +1 @@
Subproject commit be0f49a8d80a8489e9f26f765167c3239c0c92c1 Subproject commit a4d0c1366256d4e552990bde63734b7ceea0ad80

View File

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

View File

@ -57,95 +57,120 @@ ColumnLayout {
if(chatContentRoot.height > 0) { if(chatContentRoot.height > 0) {
chatInput.forceInputActiveFocus() 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 { StatusChatToolBar {
id: topBar id: topBar
z: parent.z + 1
Layout.fillWidth: true Layout.fillWidth: true
toolbarComponent: statusChatInfoButton
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;
}
}
membersButton.visible: { membersButton.visible: {
if(!chatContentModule || chatContentModule.chatDetails.type === Constants.chatType.publicChat) if(!chatContentModule || chatContentModule.chatDetails.type === Constants.chatType.publicChat)
@ -258,7 +283,7 @@ ColumnLayout {
) )
} }
onAddRemoveGroupMember: { onAddRemoveGroupMember: {
chatContentRoot.rootStore.addRemoveGroupMember(); topBar.toolbarComponent = contactsSelector
} }
onFetchMoreMessages: { onFetchMoreMessages: {
chatContentRoot.rootStore.messageStore.requestMoreMessages(); chatContentRoot.rootStore.messageStore.requestMoreMessages();
@ -298,7 +323,7 @@ ColumnLayout {
Connections { Connections {
target: mainModule target: mainModule
onOnlineStatusChanged: { onOnlineStatusChanged: {
if (connected == isConnected) return; if (connected === isConnected) return;
isConnected = connected; isConnected = connected;
if(isConnected){ if(isConnected){
timer.setTimeout(function(){ timer.setTimeout(function(){

View File

@ -46,6 +46,8 @@ StatusPopupMenu {
signal fetchMoreMessages(int timeFrame) signal fetchMoreMessages(int timeFrame)
signal addRemoveGroupMember() signal addRemoveGroupMember()
width: root.amIChatAdmin && (root.chatType === Constants.chatType.privateGroupChat) ? 207 : implicitWidth
StatusMenuItem { StatusMenuItem {
id: viewProfileMenuItem id: viewProfileMenuItem
text: { text: {
@ -72,18 +74,12 @@ StatusPopupMenu {
} }
} }
// TODO needs to be implemented StatusMenuItem {
// This should open up the ad-hoc group chat creation view text: qsTr("Add / remove from group")
// Design https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=417%3A243810 icon.name: "add-to-dm"
// StatusMenuItem { enabled: root.amIChatAdmin && (root.chatType === Constants.chatType.privateGroupChat)
// text: qsTr("Add / remove from group") onTriggered: { root.addRemoveGroupMember() }
// icon.name: "notification" }
// enabled: (root.chatType === Constants.chatType.privateGroupChat &&
// amIChatAdmin)
// onTriggered: {
// root.addRemoveGroupMember();
// }
// }
StatusMenuSeparator { StatusMenuSeparator {
visible: viewProfileMenuItem.enabled visible: viewProfileMenuItem.enabled

View File

@ -88,6 +88,7 @@ Page {
color: Theme.palette.statusAppLayout.rightPanelBackgroundColor color: Theme.palette.statusAppLayout.rightPanelBackgroundColor
} }
// TODO: Could it be replaced to `GroupChatPanel`?
header: RowLayout { header: RowLayout {
id: headerRow id: headerRow
width: parent.width width: parent.width