// // MemberList.swift // Monal // // Created by Jan on 28.05.22. // Copyright © 2022 Monal.im. All rights reserved. // import OrderedCollections struct ActionSheetPrompt { var title: Text = Text("") var message: Text = Text("") var closure: ()->Void = { } } struct MemberList: View { private let account: xmpp @State private var ownAffiliation: String @StateObject var muc: ObservableKVOWrapper @State private var memberList: OrderedSet> @State private var affiliations: Dictionary, String> @State private var online: Dictionary, Bool> @State private var nicknames: Dictionary, String> @State private var navigationActive: ObservableKVOWrapper? @State private var showAlert = false @State private var alertPrompt = AlertPrompt(dismissLabel: Text("Close")) @State private var showActionSheet = false @State private var actionSheetPrompt = ActionSheetPrompt() @StateObject private var overlay = LoadingOverlayState() init(mucContact: ObservableKVOWrapper) { account = mucContact.obj.account! as xmpp _muc = StateObject(wrappedValue:mucContact) _ownAffiliation = State(wrappedValue:kMucAffiliationNone) _memberList = State(wrappedValue:OrderedSet>()) _affiliations = State(wrappedValue:[:]) _online = State(wrappedValue:[:]) _nicknames = State(wrappedValue:[:]) } func updateMemberlist() { memberList = getContactList(viewContact:self.muc) ownAffiliation = DataLayer.sharedInstance().getOwnAffiliation(inGroupOrChannel:self.muc.obj) ?? kMucAffiliationNone affiliations.removeAll(keepingCapacity:true) online.removeAll(keepingCapacity:true) nicknames.removeAll(keepingCapacity:true) for memberInfo in Array(DataLayer.sharedInstance().getMembersAndParticipants(ofMuc:self.muc.contactJid, forAccountID:account.accountID)) { DDLogVerbose("Got member/participant entry: \(String(describing:memberInfo))") guard let jid = memberInfo["participant_jid"] as? String ?? memberInfo["member_jid"] as? String else { continue } let contact = ObservableKVOWrapper(MLContact.createContact(fromJid:jid, andAccountID:account.accountID)) nicknames[contact] = memberInfo["room_nick"] as? String if !memberList.contains(contact) { continue } affiliations[contact] = memberInfo["affiliation"] as? String ?? kMucAffiliationNone if let num = memberInfo["online"] as? NSNumber { online[contact] = num.boolValue } else { online[contact] = false } } //this is needed to improve sorting speed var contactNames: [ObservableKVOWrapper:String] = [:] for contact in memberList { contactNames[contact] = contact.obj.contactDisplayName(withFallback:nicknames[contact], andSelfnotesPrefix:false) } //sort our member list memberList.sort { ( (online[$0]! ? 0 : 1), mucAffiliationToInt(affiliations[$0]), (contactNames[$0]!.lowercased()), ($0.contactJid as String) ) < ( (online[$1]! ? 0 : 1), mucAffiliationToInt(affiliations[$1]), (contactNames[$1]!.lowercased()), ($1.contactJid as String) ) } } func promisifyAction(action: @escaping ()->Void) -> Promise { return promisifyMucAction(account:self.account, mucJid:self.muc.contactJid, action:action) } func showAlert(title: Text, description: Text) { self.alertPrompt.title = title self.alertPrompt.message = description self.showAlert = true } func showActionSheet(title: Text, description: Text, closure: @escaping ()->Void) { self.actionSheetPrompt.title = title self.actionSheetPrompt.message = description self.actionSheetPrompt.closure = closure self.showActionSheet = true } func ownUserHasAffiliationToRemove(contact: ObservableKVOWrapper) -> Bool { //we don't want to set affiliation=none in channels using deletion swipe (this does not delete the user) if self.muc.mucType == kMucTypeChannel { return false } if contact.contactJid == account.connectionProperties.identity.jid { return false } if let contactAffiliation = affiliations[contact] { if ownAffiliation == kMucAffiliationOwner { return true } else if ownAffiliation == kMucAffiliationAdmin && (contactAffiliation != kMucAffiliationOwner && contactAffiliation != kMucAffiliationAdmin) { return true } } return false } func actionsAllowed(for contact:ObservableKVOWrapper) -> [String] { if let contactAffiliation = affiliations[contact], let contactOnline = online[contact] { var reinviteEntry: [String] = [] if !contactOnline { reinviteEntry = [kMucActionReinvite] } if self.muc.mucType == kMucTypeGroup { if ownAffiliation == kMucAffiliationOwner { return [/*kMucActionShowProfile*/] + reinviteEntry + [kMucAffiliationOwner, kMucAffiliationAdmin, kMucAffiliationMember, kMucAffiliationOutcast] } else { //only admin left, because other affiliations don't call actionsAllowed at all if [kMucAffiliationMember, kMucAffiliationOutcast].contains(contactAffiliation) { return [/*kMucActionShowProfile*/] + reinviteEntry + [kMucAffiliationMember, kMucAffiliationOutcast] } else { //if this contact is a co-admin or owner, we aren't allowed to do much to their affiliation //return contact affiliation because that should be displayed as selected in picker return [/*kMucActionShowProfile*/] + reinviteEntry + [contactAffiliation] } } } else { if ownAffiliation == kMucAffiliationOwner { return [/*kMucActionShowProfile*/] + reinviteEntry + [kMucAffiliationOwner, kMucAffiliationAdmin, kMucAffiliationMember, kMucAffiliationNone, kMucAffiliationOutcast] } else { //only admin left, because other affiliations don't call actionsAllowed at all if [kMucAffiliationMember, kMucAffiliationNone, kMucAffiliationOutcast].contains(contactAffiliation) { return [/*kMucActionShowProfile*/] + reinviteEntry + [kMucAffiliationMember, kMucAffiliationNone, kMucAffiliationOutcast] } else { //if this contact is a co-admin or owner, we aren't allowed to do much to their affiliation //return contact affiliation because that should be displayed as selected in picker return [/*kMucActionShowProfile*/] + reinviteEntry + [contactAffiliation] } } } } //fallback (should hopefully never be needed) DDLogWarn("Fallback for group/channel \(String(describing:self.muc.contactJid as String)): affiliation=\(String(describing:affiliations[contact])), online=\(String(describing:online[contact]))") if self.muc.mucType == kMucTypeGroup { return [/*kMucActionShowProfile,*/ kMucActionReinvite] } else { return [/*kMucActionShowProfile,*/ kMucActionReinvite, kMucAffiliationNone] } } @ViewBuilder func makePickerView(contact: ObservableKVOWrapper) -> some View { Picker(selection: Binding( get: { affiliations[contact] ?? kMucAffiliationNone }, set: { newAffiliation in if newAffiliation == affiliations[contact] { return } if newAffiliation == kMucActionShowProfile { DDLogVerbose("Activating navigation to \(String(describing:contact))") navigationActive = contact } else if newAffiliation == kMucActionReinvite { //first remove potential ban, then reinvite var outcastResolution: Promise = Promise.value(nil) if affiliations[contact] == kMucAffiliationOutcast { outcastResolution = showPromisingLoadingOverlay(self.overlay, headlineView: Text("Unblocking user"), descriptionView: Text("Unblocking user for this group/channel: \(contact.contactJid as String)")) { promisifyAction { account.mucProcessor.setAffiliation(self.muc.mucType == kMucTypeGroup ? kMucAffiliationMember : kMucAffiliationNone, ofUser:contact.contactJid, inMuc:self.muc.contactJid) } } } outcastResolution.then { _ in showPromisingLoadingOverlay(self.overlay, headlineView: Text("Inviting user"), descriptionView: Text("Inviting user to this group/channel: \(contact.contactJid as String)")) { promisifyAction { account.mucProcessor.inviteUser(contact.contactJid, inMuc: self.muc.contactJid) } }.catch { error in showAlert(title:Text("Error inviting user!"), description:Text("\(String(describing:error))")) } return Guarantee.value(()) }.catch { error in showAlert(title:Text("Error unblocking user!"), description:Text("\(String(describing:error))")) } } else if newAffiliation == kMucAffiliationOutcast { showActionSheet(title: Text("Block user?"), description: Text("Do you want to block this user from entering this group/channel?")) { DDLogVerbose("Changing affiliation of \(String(describing:contact)) to: \(String(describing:newAffiliation))...") showPromisingLoadingOverlay(self.overlay, headlineView: Text("Blocking member"), descriptionView: Text("Blocking \(contact.contactJid as String)")) { promisifyAction { account.mucProcessor.setAffiliation(newAffiliation, ofUser:contact.contactJid, inMuc:self.muc.contactJid) } }.catch { error in showAlert(title:Text("Error blocking user!"), description:Text("\(String(describing:error))")) } } } else { DDLogVerbose("Changing affiliation of \(String(describing:contact)) to: \(String(describing:newAffiliation))...") showPromisingLoadingOverlay(self.overlay, headlineView: Text("Changing affiliation"), descriptionView: Text("Changing affiliation to \(mucAffiliationToString(affiliations[contact])): \(contact.contactJid as String)")) { promisifyAction { account.mucProcessor.setAffiliation(newAffiliation, ofUser:contact.contactJid, inMuc:self.muc.contactJid) } }.catch { error in showAlert(title:Text("Error changing affiliation!"), description:Text("\(String(describing:error))")) } } } ), label: EmptyView()) { ForEach(actionsAllowed(for:contact), id:\.self) { affiliation in Text(mucAffiliationToString(affiliation)).tag(affiliation) } }.collapsedPickerStyle(accessibilityLabel: Text("Change affiliation")) } var body: some View { List { Section(header: Text("\(self.muc.contactDisplayName as String) (affiliation: \(mucAffiliationToString(ownAffiliation)))")) { if ownAffiliation == kMucAffiliationOwner || ownAffiliation == kMucAffiliationAdmin { NavigationLink(destination: LazyClosureView(ContactPicker(account, initializeFrom: memberList, allowRemoval: false) { newMemberList in for member in newMemberList { if !memberList.contains(member) { if self.muc.mucType == kMucTypeGroup { showPromisingLoadingOverlay(self.overlay, headlineView: Text("Adding new member"), descriptionView: Text("Adding \(member.contactJid as String)...")) { promisifyAction { account.mucProcessor.setAffiliation(kMucAffiliationMember, ofUser:member.contactJid, inMuc:self.muc.contactJid) } }.done { _ in showPromisingLoadingOverlay(self.overlay, headlineView: Text("Inviting new member"), descriptionView: Text("Adding \(member.contactJid as String)...")) { promisifyAction { account.mucProcessor.inviteUser(member.contactJid, inMuc: self.muc.contactJid) } }.catch { error in showAlert(title:Text("Error inviting new member!"), description:Text("\(String(describing:error))")) } }.catch { error in showAlert(title:Text("Error adding new member!"), description:Text("\(String(describing:error))")) } } else { showPromisingLoadingOverlay(self.overlay, headlineView: Text("Inviting new participant"), descriptionView: Text("Adding \(member.contactJid as String)...")) { promisifyAction { account.mucProcessor.inviteUser(member.contactJid, inMuc: self.muc.contactJid) } }.catch { error in showAlert(title:Text("Error inviting new participant!"), description:Text("\(String(describing:error))")) } } } } })) { if self.muc.mucType == kMucTypeGroup { Text("Add members to group") } else { Text("Invite participants to channel") } } } ForEach(memberList, id:\.self) { contact in var isDeletable: Bool { ownUserHasAffiliationToRemove(contact: contact) } if !contact.isSelfChat { HStack { HStack { ContactEntry(contact:contact, fallback:nicknames[contact]) { Text("Affiliation: \(mucAffiliationToString(affiliations[contact]))\(!(online[contact] ?? false) ? Text(" (offline)") : Text(""))") //.foregroundColor(Color(UIColor.secondaryLabel)) .font(.footnote) } Spacer() } .accessibilityLabel(Text("Open Profile of \(contact.contactDisplayName as String)")) //invisible navigation link that can be triggered programmatically .background( NavigationLink(destination: LazyClosureView(ContactDetails(delegate:nil, contact:contact)), tag:contact, selection:$navigationActive) { EmptyView() } .opacity(0) ) if ownAffiliation == kMucAffiliationOwner || ownAffiliation == kMucAffiliationAdmin { makePickerView(contact:contact) .fixedSize() .offset(x:8, y:0) } } .applyClosure { view in if !(online[contact] ?? false) { view.opacity(0.5) } else { view } } .swipeActions(allowsFullSwipe: false) { Button("Delete") { showActionSheet(title: Text("Remove \(mucAffiliationToString(affiliations[contact]))?"), description: self.muc.mucType == kMucTypeGroup ? Text("Do you want to remove that user from this group? That user won't be able to enter it again until added back to the group.") : Text("Do you want to remove that user from this channel? That user will be able to enter it again if you don't block them.")) { showPromisingLoadingOverlay(self.overlay, headlineView: Text("Removing \(mucAffiliationToString(affiliations[contact]))"), descriptionView: Text("Removing \(contact.contactJid as String)...")) { promisifyAction { account.mucProcessor.setAffiliation(kMucAffiliationNone, ofUser: contact.contactJid, inMuc: self.muc.contactJid) } }.catch { error in showAlert(title:Text("Error removing user!"), description:Text("\(String(describing:error))")) } } } .tint(.red) .disabled(!isDeletable) } } } } } .animation(.default, value: memberList) .actionSheet(isPresented: $showActionSheet) { ActionSheet( title: actionSheetPrompt.title, message: actionSheetPrompt.message, buttons: [ .cancel(), .destructive( Text("Yes"), action: actionSheetPrompt.closure ) ] ) } .alert(isPresented: $showAlert, content: { Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton: .default(alertPrompt.dismissLabel)) }) .addLoadingOverlay(overlay) .navigationBarTitle(Text("Group Members"), displayMode: .inline) .onAppear { updateMemberlist() } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalMucParticipantsAndMembersUpdated")).receive(on: RunLoop.main)) { notification in if let xmppAccount = notification.object as? xmpp, let contact = notification.userInfo?["contact"] as? MLContact { DDLogVerbose("Got muc participants/members update from account \(xmppAccount)...") //only trigger update if we are either in a group type muc or have admin/owner priviledges //all other cases will close this view anyways, it makes no sense to update everything directly before hiding thsi view if contact == self.muc && (contact.mucType == kMucTypeGroup || [kMucAffiliationOwner, kMucAffiliationAdmin].contains(DataLayer.sharedInstance().getOwnAffiliation(inGroupOrChannel:self.muc.obj) ?? kMucAffiliationNone)) { updateMemberlist() } } } } } extension UIPickerView { override open func didMoveToSuperview() { super.didMoveToSuperview() self.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) } } struct MemberList_Previews: PreviewProvider { static var previews: some View { MemberList(mucContact:ObservableKVOWrapper(MLContact.makeDummyContact(2))); } }