// // ContactDetails.swift // Monal // // Created by Jan on 22.10.21. // Copyright © 2021 Monal.im. All rights reserved. // struct ContactDetails: View { @Environment(\.presentationMode) private var presentationMode @State private var ownRole = kMucRoleParticipant @State private var ownAffiliation = kMucAffiliationNone @StateObject var contact: ObservableKVOWrapper @State private var showingRemoveAvatarConfirmation = false @State private var showingBlockContactConfirmation = false @State private var showingCannotBlockAlert = false @State private var showingRemoveContactConfirmation = false @State private var showingAddContactConfirmation = false @State private var showingClearHistoryConfirmation = false @State private var showingResetOmemoSessionConfirmation = false @State private var showingCannotEncryptAlert = false @State private var showingShouldDisableEncryptionAlert = false @State private var isEditingNickname = false @State private var inputImage: UIImage? @State private var showingImagePicker = false @State private var showingSheetEditSubject = false @State private var showingDestroyConfirmation = false @State private var alertPrompt = AlertPrompt(dismissLabel: Text("Close")) @State private var showAlert = false @State private var success = false @State private var successCallback: monal_void_block_t? @StateObject private var overlay = LoadingOverlayState() var delegate: SheetDismisserProtocol? private var account: xmpp init(delegate: SheetDismisserProtocol?, contact: ObservableKVOWrapper) { self.delegate = delegate _contact = StateObject(wrappedValue: contact) self.account = contact.obj.account! } private func updateRoleAndAffiliation() { if contact.isMuc { self.ownRole = DataLayer.sharedInstance().getOwnRole(inGroupOrChannel: contact.obj) ?? kMucRoleNone self.ownAffiliation = DataLayer.sharedInstance().getOwnAffiliation(inGroupOrChannel:contact.obj) ?? kMucAffiliationNone } else { self.ownRole = kMucRoleParticipant self.ownAffiliation = kMucAffiliationNone } } private func errorAlert(title: Text, message: Text = Text("")) { alertPrompt.title = title alertPrompt.message = message showAlert = true } private func successAlert(title: Text, message: Text = Text("")) { alertPrompt.title = title alertPrompt.message = message showAlert = true success = true // < dismiss entire view on close } private func showImagePicker() { #if targetEnvironment(macCatalyst) let picker = DocumentPickerViewController( supportedTypes: [UTType.image], onPick: { url in if let imageData = try? Data(contentsOf: url) { if let loadedImage = UIImage(data: imageData) { self.inputImage = loadedImage } } }, onDismiss: { //do nothing on dismiss } ) UIApplication.shared.windows.first?.rootViewController?.present(picker, animated: true) #else showingImagePicker = true #endif } var body: some View { Form { Section { VStack(spacing: 20) { if !contact.isSelfChat { Image(uiImage: contact.avatar) .resizable() .scaledToFit() .applyClosure {view in if contact.isMuc { if ownAffiliation == kMucAffiliationOwner { view.accessibilityLabel((contact.mucType == kMucTypeGroup) ? Text("Change Group Avatar") : Text("Change Channel Avatar")) .onTapGesture { showImagePicker() } .addTopRight { if contact.hasAvatar { Button(action: { showingRemoveAvatarConfirmation = true }, label: { Image(systemName: "xmark.circle.fill") .resizable() .frame(width: 24.0, height: 24.0) .accessibilityLabel((contact.mucType == kMucTypeGroup) ? Text("Remove Group Avatar") : Text("Remove Channel Avatar")) .symbolRenderingMode(.palette) .foregroundStyle(.white, .red) }) .buttonStyle(.borderless) .offset(x: 8, y: -8) } else { Button(action: { showImagePicker() }, label: { Image(systemName: "pencil.circle.fill") .resizable() .frame(width: 24.0, height: 24.0) .accessibilityLabel((contact.mucType == kMucTypeGroup) ? Text("Change Group Avatar") : Text("Change Channel Avatar")) }) .buttonStyle(.borderless) .offset(x: 8, y: -8) } } } else { view.accessibilityLabel((contact.mucType == kMucTypeGroup) ? Text("Group Avatar") : Text("Channel Avatar")) } } else { view.accessibilityLabel(Text("Avatar")) } } .frame(width: 150, height: 150, alignment: .center) .shadow(radius: 7) .actionSheet(isPresented: $showingRemoveAvatarConfirmation) { ActionSheet( title: Text("Really remove avatar?"), message: Text("This will remove the current avatar image and revert this group/channel to the default one."), buttons: [ .cancel(), .destructive( Text("Yes"), action: { showPromisingLoadingOverlay(overlay, headlineView:Text("Removing avatar..."), descriptionView:Text("")) { promisifyMucAction(account:account, mucJid:contact.contactJid) { self.account.mucProcessor.publishAvatar(nil, forMuc: contact.contactJid) } }.catch { error in errorAlert(title: Text("Error removing avatar!"), message: Text("\(String(describing:error))")) hideLoadingOverlay(overlay) } } ) ] ) } } Button { UIPasteboard.general.setValue(contact.contactJid as String, forPasteboardType:UTType.utf8PlainText.identifier as String) UIAccessibility.post(notification: .announcement, argument: "JID Copied") } label: { HStack { Text(contact.contactJid as String) Image(systemName: "doc.on.doc") .foregroundColor(.primary) .accessibilityHidden(true) } .accessibilityHint("Copies JID") } .buttonStyle(.borderless) // //TODO: wait for account edit to become swiftui // if contact.isSelfChat { // Button { // //TODO: open account edit // } label: { // Text("Open account settings") // .accessibilityHint("Open account settings") // } // .buttonStyle(.borderless) // } //only show account jid if more than one is configured if MLXMPPManager.sharedInstance().connectedXMPP.count > 1 && !contact.isSelfChat { Text("Account: \(account.connectionProperties.identity.jid)") } if !contact.isSelfChat && !contact.isMuc { if let lastInteractionTime = contact.lastInteractionTime as Date? { if lastInteractionTime.timeIntervalSince1970 > 0 { Text(String(format: NSLocalizedString("Last seen: %@", comment: ""), DateFormatter.localizedString(from: lastInteractionTime, dateStyle: DateFormatter.Style.short, timeStyle: DateFormatter.Style.short))) } else { Text(String(format: NSLocalizedString("Last seen: %@", comment: ""), NSLocalizedString("now", comment: ""))) } } else { Text(String(format: NSLocalizedString("Last seen: %@", comment: ""), NSLocalizedString("unknown", comment: ""))) } } if !contact.isMuc, let statusMessage = contact.statusMessage as String?, statusMessage.count > 0 { VStack { Text("Status message:") Text(contact.statusMessage as String) .fixedSize(horizontal: false, vertical: true) } } if contact.isMuc && ((contact.groupSubject as String).count > 0 || ownRole == kMucRoleModerator) { VStack { if ownRole == kMucRoleModerator { Button { showingSheetEditSubject.toggle() } label: { if contact.obj.mucType == kMucTypeGroup { HStack { Text("Group subject:") Spacer().frame(width:8) Image(systemName: "pencil") .foregroundColor(.primary) .accessibilityHidden(true) } .accessibilityHint("Edit Group Subject") } else { HStack { Text("Channel subject:") Spacer().frame(width:8) Image(systemName: "pencil") .foregroundColor(.primary) .accessibilityHidden(true) } .accessibilityHint("Edit Channel Subject") } } .buttonStyle(.borderless) } else { Text("Group subject:") } Text(contact.groupSubject as String) .fixedSize(horizontal: false, vertical: true) } } } .foregroundColor(.primary) .padding([.top, .bottom]) .frame(maxWidth: .infinity) } // info/nondestructive buttons Section { if !contact.isSelfChat { Button { if contact.isMuc { if !contact.isMuted && !contact.isMentionOnly { contact.obj.toggleMentionOnly(true) } else if !contact.isMuted && contact.isMentionOnly { contact.obj.toggleMentionOnly(false) contact.obj.toggleMute(true) } else { contact.obj.toggleMentionOnly(false) contact.obj.toggleMute(false) } } else { contact.obj.toggleMute(!contact.isMuted) } } label: { if contact.isMuted { Label { contact.isMuc ? Text("Notifications disabled") : Text("Contact is muted") } icon: { Image(systemName: "bell.slash.fill") } .foregroundStyle(Color.red) } else if contact.isMuc && contact.isMentionOnly { Label { Text("Notify only when mentioned") } icon: { Image(systemName: "bell.badge") } .foregroundStyle(Color.primary) } else { Label { contact.isMuc ? Text("Notify on all messages") : Text("Contact is not muted") } icon: { Image(systemName: "bell.fill") } .foregroundStyle(Color.green) } } } #if !DISABLE_OMEMO if (!contact.isMuc || (contact.isMuc && contact.mucType == kMucTypeGroup)) && !HelperTools.isContactBlacklistedForEncryption(contact.obj) { Button { if contact.isEncrypted { showingShouldDisableEncryptionAlert = true } else { showingCannotEncryptAlert = !contact.obj.toggleEncryption(!contact.isEncrypted) } } label: { if contact.isEncrypted { Label { Text("Messages are encrypted") } icon: { Image(systemName: "lock.fill") } .foregroundStyle(Color.green) } else { Label { Text("Messages are NOT encrypted") } icon: { Image(systemName: "lock.open.fill") } .foregroundStyle(Color.red) } } .alert(isPresented: $showingCannotEncryptAlert) { Alert(title: Text("Encryption Not Supported"), message: Text("This contact does not appear to have any devices that support encryption, please try again later if you think this is wrong."), dismissButton: .default(Text("Close"))) } .actionSheet(isPresented: $showingShouldDisableEncryptionAlert) { ActionSheet( title: Text("Disable encryption?"), message: Text("Do you really want to disable encryption for this contact?"), buttons: [ .cancel( Text("No, keep encryption activated"), action: { } ), .destructive( Text("Yes, deactivate encryption"), action: { showingCannotEncryptAlert = !contact.obj.toggleEncryption(!contact.isEncrypted) } ) ] ) } //.buttonStyle(BorderlessButtonStyle()) } #endif if contact.isMuc && ownAffiliation == kMucAffiliationOwner { let label = contact.obj.mucType == kMucTypeGroup ? NSLocalizedString("Rename Group", comment:"") : NSLocalizedString("Rename Channel", comment:"") TextField(label, text: $contact.fullNameView, onEditingChanged: { isEditingNickname = $0 }) .accessibilityLabel(contact.obj.mucType == kMucTypeGroup ? Text("Group name") : Text("Channel name")) .addClearButton(isEditing: isEditingNickname, text: $contact.fullNameView) } else if !contact.isMuc && !contact.isSelfChat { TextField(NSLocalizedString("Rename Contact", comment: "placeholder text in contact details"), text: $contact.nickNameView, onEditingChanged: { isEditingNickname = $0 }) .accessibilityLabel(Text("Nickname")) .addClearButton(isEditing: isEditingNickname, text: $contact.nickNameView) } Toggle(isOn: Binding(get: { contact.isPinned }, set: { contact.obj.togglePinnedChat($0) })) { Text("Pin Chat") } #if !DISABLE_OMEMO if !HelperTools.isContactBlacklistedForEncryption(contact.obj) && !contact.isSelfChat { if !contact.isMuc || contact.mucType == kMucTypeGroup { NavigationLink(destination: LazyClosureView(OmemoKeysView(omemoKeys: OmemoKeysForChat(viewContact: contact)))) { Text("Encryption Keys") } } } #endif if !contact.isMuc && !contact.isSelfChat { NavigationLink(destination: LazyClosureView(ContactResources(contact: contact))) { Text("Resources") } } let accountJid = account.connectionProperties.identity.jid let displayName = contact.contactDisplayName as String let sharedUrl = HelperTools.getSharedDocumentsURL(forPathComponents:[accountJid, displayName]) if UIApplication.shared.canOpenURL(sharedUrl) && FileManager.default.fileExists(atPath:sharedUrl.path) { NavigationLink(destination: LazyClosureView{MediaGalleryView(contact: contact.contactJid as String, accountID: contact.accountID)}) { Text("Shared Media") } Button(action: { UIApplication.shared.open(sharedUrl, options:[:]) }) { Text("Shared Files") } } NavigationLink(destination: LazyClosureView(BackgroundSettings(contact:contact))) { Text("Change Chat Background") } if contact.obj.isMuc && contact.obj.mucType == kMucTypeGroup { NavigationLink(destination: LazyClosureView(MemberList(mucContact:contact))) { Text("Group Members") } } else if contact.obj.isMuc && contact.obj.mucType == kMucTypeChannel { if [kMucAffiliationOwner, kMucAffiliationAdmin].contains(ownAffiliation) { NavigationLink(destination: LazyClosureView(MemberList(mucContact:contact))) { Text("Channel Participants") } } else { NavigationLink(destination: LazyClosureView(ChannelMemberList(mucContact:contact))) { Text("Channel Participants") } } } } .listStyle(.plain) Section { // the destructive section... if !contact.isSelfChat { Button(action: { if !contact.isBlocked { showingBlockContactConfirmation = true } else { showingCannotBlockAlert = !contact.obj.toggleBlocked(!contact.isBlocked) } }) { if !contact.isBlocked { Text("Block Contact") .foregroundColor(.red) } else { Text("Unblock Contact") } } .alert(isPresented: $showingCannotBlockAlert) { Alert(title: Text("Blocking/Unblocking Not Supported"), message: Text("The server does not support blocking (XEP-0191)."), dismissButton: .default(Text("Close"))) } .actionSheet(isPresented: $showingBlockContactConfirmation) { ActionSheet( title: Text("Block Contact"), message: Text("Do you really want to block this contact? You won't receive any messages from this contact."), buttons: [ .cancel(), .destructive( Text("Yes"), action: { showingCannotBlockAlert = !contact.obj.toggleBlocked(!contact.isBlocked) } ) ] ) } Group { if contact.isInRoster { Button(action: { showingRemoveContactConfirmation = true }) { if contact.isMuc { if contact.mucType == kMucTypeGroup { Text("Leave Group") .foregroundColor(.red) } else { Text("Leave Channel") .foregroundColor(.red) } } else { Text("Remove from contacts") .foregroundColor(.red) } } .actionSheet(isPresented: $showingRemoveContactConfirmation) { ActionSheet( title: Text(contact.isMuc ? NSLocalizedString("Leave this conversation", comment: "") : String(format: NSLocalizedString("Remove %@ from contacts?", comment: ""), contact.contactJid)), message: Text(contact.isMuc ? NSLocalizedString("You will no longer receive messages from this conversation", comment: "") : NSLocalizedString("They will no longer see when you are online. They may not be able to send you encrypted messages.", comment: "")), buttons: [ .cancel(), .destructive( Text("Yes"), action: { contact.obj.removeFromRoster() //this will dismiss the chatview via kMonalContactRemoved notification //NOTE: since we can get opened from objc through active chats, //NOTE: we still need to support our SheetDismisserProtocol if let delegate = self.delegate { delegate.dismiss() } else { self.presentationMode.wrappedValue.dismiss() } } ) ] ) } } else { Button(action: { showingAddContactConfirmation = true }) { if contact.isMuc { if contact.mucType == kMucTypeGroup { Text("Join Group") } else { Text("Join Channel") } } else { Text("Add to contacts") } } .actionSheet(isPresented: $showingAddContactConfirmation) { ActionSheet( title: Text(contact.isMuc ? (contact.mucType == kMucTypeGroup ? NSLocalizedString("Join Group", comment: "") : NSLocalizedString("Join Channel", comment: "")) : String(format: NSLocalizedString("Add %@ to your contacts?", comment: ""), contact.contactJid)), message: Text(contact.isMuc ? NSLocalizedString("You will receive subsequent messages from this conversation", comment: "") : NSLocalizedString("They will see when you are online. They will be able to send you encrypted messages.", comment: "")), buttons: [ .cancel(), .default( Text("Yes"), action: { contact.obj.addToRoster() } ), ] ) } } } } if ownAffiliation == kMucAffiliationOwner { Section { Button(action: { showingDestroyConfirmation = true }) { if contact.mucType == kMucTypeGroup { Text("Destroy Group").foregroundColor(.red) } else { Text("Destroy Channel").foregroundColor(.red) } } .actionSheet(isPresented: $showingDestroyConfirmation) { ActionSheet( title: contact.mucType == kMucTypeGroup ? Text("Destroy Group") : Text("Destroy Channel"), message: contact.mucType == kMucTypeGroup ? Text("Do you really want to destroy this group? Every member will be kicked out and it will be destroyed afterwards.") : Text("Do you really want to destroy this channel? Every member will be kicked out and it will be destroyed afterwards."), buttons: [ .cancel(), .destructive( Text("Yes"), action: { showPromisingLoadingOverlay(overlay, headlineView:contact.mucType == kMucTypeGroup ? Text("Destroying group...") : Text("Destroying channel..."), descriptionView:Text("")) { promisifyMucAction(account:account, mucJid:contact.contactJid) { self.account.mucProcessor.destroyRoom(contact.contactJid as String) } }.done { callback in if let callback = callback { self.successCallback = callback } successAlert(title: Text("Success"), message: contact.mucType == kMucTypeGroup ? Text("Successfully destroyed group.") : Text("Successfully destroyed channel.")) }.catch { error in errorAlert(title: Text("Error destroying group!"), message: Text("\(String(describing:error))")) } } ) ] ) } } } Button(action: { showingClearHistoryConfirmation = true }) { if contact.isMuc { if contact.obj.mucType == kMucTypeGroup { Text("Clear chat history of this group") } else { Text("Clear chat history of this channel") } } else { Text("Clear chat history of this contact") } } .foregroundColor(.red) .actionSheet(isPresented: $showingClearHistoryConfirmation) { ActionSheet( title: Text("Clear History"), message: Text("Do you really want to clear all messages exchanged in this conversation? If using OMEMO you won't even be able to load them from your server again."), buttons: [ .cancel(), .destructive( Text("Yes"), action: { contact.obj.clearHistory() } ) ] ) } } #if !DISABLE_OMEMO //omemo debug stuff, should be removed in a few months Section { // only display omemo session reset button on 1:1 and private groups if contact.obj.isMuc == false || (contact.isMuc && contact.mucType == kMucTypeGroup) { Button(action: { showingResetOmemoSessionConfirmation = true }) { Text("Reset OMEMO session") .foregroundColor(.red) } .actionSheet(isPresented: $showingResetOmemoSessionConfirmation) { ActionSheet( title: Text("Reset OMEMO session"), message: Text("Do you really want to reset the OMEMO session? You should only reset the connection if you know what you are doing!"), buttons: [ .cancel(), .destructive( Text("Yes"), action: { self.account.omemo.clearAllSessions(forJid:contact.contactJid); } ) ] ) } } } #endif } .frame(maxWidth: .infinity, maxHeight: .infinity) .tint(Color.primary) .addLoadingOverlay(overlay) .navigationBarTitle(contact.contactDisplayName as String, displayMode:.inline) .alert(isPresented: $showAlert) { Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton:.default(Text("Close"), action: { showAlert = false if self.success == true { if let callback = self.successCallback { callback() } //close muc ui and leave chat ui of this muc if let activeChats = (UIApplication.shared.delegate as! MonalAppDelegate).activeChats { activeChats.presentChat(with:nil) } } })) } .sheet(isPresented: $showingSheetEditSubject) { LazyClosureView(EditGroupSubject(contact: contact)) } .sheet(isPresented:$showingImagePicker) { ImagePicker(image:$inputImage) } .sheet(isPresented: $inputImage.optionalMappedToBool()) { ImageCropView(originalImage: inputImage!, configureBlock: { cropViewController in cropViewController.aspectRatioPreset = .presetSquare cropViewController.aspectRatioLockEnabled = true cropViewController.aspectRatioPickerButtonHidden = true cropViewController.resetAspectRatioEnabled = false }, onCanceled: { inputImage = nil }) { (image, cropRect, angle) in showPromisingLoadingOverlay(overlay, headlineView:Text("Uploading avatar..."), descriptionView:Text("")) { promisifyMucAction(account:account, mucJid:contact.contactJid) { self.account.mucProcessor.publishAvatar(image, forMuc: contact.contactJid) } }.catch { error in errorAlert(title: Text("Error changing avatar!"), message: Text("\(String(describing:error))")) hideLoadingOverlay(overlay) } } } .onChange(of:contact.avatar as UIImage) { _ in hideLoadingOverlay(overlay) } .onAppear { self.updateRoleAndAffiliation() } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalMucParticipantsAndMembersUpdated")).receive(on: RunLoop.main)) { notification in if let xmppAccount = notification.object as? xmpp, let notificationContact = notification.userInfo?["contact"] as? MLContact { DDLogVerbose("Got muc participants/members update from account \(xmppAccount)...") if notificationContact == contact { self.updateRoleAndAffiliation() } } } } } struct ContactDetails_Previews: PreviewProvider { static var previews: some View { ContactDetails(delegate:nil, contact:ObservableKVOWrapper(MLContact.makeDummyContact(0))) ContactDetails(delegate:nil, contact:ObservableKVOWrapper(MLContact.makeDummyContact(1))) ContactDetails(delegate:nil, contact:ObservableKVOWrapper(MLContact.makeDummyContact(2))) ContactDetails(delegate:nil, contact:ObservableKVOWrapper(MLContact.makeDummyContact(3))) ContactDetails(delegate:nil, contact:ObservableKVOWrapper(MLContact.makeDummyContact(4))) } }