another.im-ios/Monal/Classes/ContactDetails.swift
2024-11-18 15:53:52 +01:00

702 lines
37 KiB
Swift

//
// 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<MLContact>
@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<MLContact>) {
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>(MLContact.makeDummyContact(0)))
ContactDetails(delegate:nil, contact:ObservableKVOWrapper<MLContact>(MLContact.makeDummyContact(1)))
ContactDetails(delegate:nil, contact:ObservableKVOWrapper<MLContact>(MLContact.makeDummyContact(2)))
ContactDetails(delegate:nil, contact:ObservableKVOWrapper<MLContact>(MLContact.makeDummyContact(3)))
ContactDetails(delegate:nil, contact:ObservableKVOWrapper<MLContact>(MLContact.makeDummyContact(4)))
}
}