// // ChatListNavLink.swift // SimpleX // // Created by Evgeny Poberezkin on 01/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // import SwiftUI import SimpleXChat private let rowHeights: [DynamicTypeSize: CGFloat] = [ .xSmall: 68, .small: 72, .medium: 76, .large: 80, .xLarge: 88, .xxLarge: 94, .xxxLarge: 104, .accessibility1: 90, .accessibility2: 100, .accessibility3: 120, .accessibility4: 130, .accessibility5: 140 ] struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dynamicTypeSize) private var dynamicTypeSize @ObservedObject var chat: Chat @State private var showContactRequestDialog = false @State private var showJoinGroupDialog = false @State private var showContactConnectionInfo = false @State private var showInvalidJSON = false var body: some View { switch chat.chatInfo { case let .direct(contact): contactNavLink(contact) case let .group(groupInfo): groupNavLink(groupInfo) case let .contactRequest(cReq): contactRequestNavLink(cReq) case let .contactConnection(cConn): contactConnectionNavLink(cConn) case let .invalidJSON(json): invalidJSONPreview(json) } } @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, label: { ChatPreviewView(chat: chat) } ) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() toggleFavoriteButton() toggleNtfsButton(chat) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { if !chat.chatItems.isEmpty { clearChatButton() } Button { AlertManager.shared.showAlert( contact.ready || !contact.active ? deleteContactAlert(chat.chatInfo) : deletePendingContactAlert(chat, contact) ) } label: { Label("Delete", systemImage: "trash") } .tint(.red) } .frame(height: rowHeights[dynamicTypeSize]) } @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { switch (groupInfo.membership.memberStatus) { case .memInvited: ChatPreviewView(chat: chat) .frame(height: rowHeights[dynamicTypeSize]) .swipeActions(edge: .trailing, allowsFullSwipe: true) { joinGroupButton() if groupInfo.canDelete { deleteGroupChatButton(groupInfo) } } .onTapGesture { showJoinGroupDialog = true } .confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) { Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") { joinGroup(groupInfo.groupId) } Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } } } case .memAccepted: ChatPreviewView(chat: chat) .frame(height: rowHeights[dynamicTypeSize]) .onTapGesture { AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) } .swipeActions(edge: .trailing) { if (groupInfo.membership.memberCurrent) { leaveGroupChatButton(groupInfo) } if groupInfo.canDelete { deleteGroupChatButton(groupInfo) } } default: NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, label: { ChatPreviewView(chat: chat) }, disabled: !groupInfo.ready ) .frame(height: rowHeights[dynamicTypeSize]) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() toggleFavoriteButton() toggleNtfsButton(chat) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { if !chat.chatItems.isEmpty { clearChatButton() } if (groupInfo.membership.memberCurrent) { leaveGroupChatButton(groupInfo) } if groupInfo.canDelete { deleteGroupChatButton(groupInfo) } } } } private func joinGroupButton() -> some View { Button { joinGroup(chat.chatInfo.apiId) } label: { Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward") } .tint(chat.chatInfo.incognito ? .indigo : .accentColor) } @ViewBuilder private func markReadButton() -> some View { if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat { Button { Task { await markChatRead(chat) } } label: { Label("Read", systemImage: "checkmark") } .tint(Color.accentColor) } else { Button { Task { await markChatUnread(chat) } } label: { Label("Unread", systemImage: "circlebadge.fill") } .tint(Color.accentColor) } } @ViewBuilder private func toggleFavoriteButton() -> some View { if chat.chatInfo.chatSettings?.favorite == true { Button { toggleChatFavorite(chat, favorite: false) } label: { Label("Unfav.", systemImage: "star.slash") } .tint(.green) } else { Button { toggleChatFavorite(chat, favorite: true) } label: { Label("Favorite", systemImage: "star.fill") } .tint(.green) } } private func clearChatButton() -> some View { Button { AlertManager.shared.showAlert(clearChatAlert()) } label: { Label("Clear", systemImage: "gobackward") } .tint(Color.orange) } private func leaveGroupChatButton(_ groupInfo: GroupInfo) -> some View { Button { AlertManager.shared.showAlert(leaveGroupAlert(groupInfo)) } label: { Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") } .tint(Color.yellow) } private func deleteGroupChatButton(_ groupInfo: GroupInfo) -> some View { Button { AlertManager.shared.showAlert(deleteGroupAlert(groupInfo)) } label: { Label("Delete", systemImage: "trash") } .tint(.red) } private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { ContactRequestView(contactRequest: contactRequest, chat: chat) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } } label: { Label("Accept", systemImage: "checkmark") } .tint(.accentColor) Button { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } } label: { Label("Accept incognito", systemImage: "theatermasks") } .tint(.indigo) Button { AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest)) } label: { Label("Reject", systemImage: "multiply") } .tint(.red) } .frame(height: rowHeights[dynamicTypeSize]) .onTapGesture { showContactRequestDialog = true } .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } } Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } } Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } } } } private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { ContactConnectionView(chat: chat) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection) { a in AlertManager.shared.showAlertMsg(title: a.title, message: a.message) }) } label: { Label("Delete", systemImage: "trash") } .tint(.red) Button { showContactConnectionInfo = true } label: { Label("Name", systemImage: "pencil") } .tint(.accentColor) } .frame(height: rowHeights[dynamicTypeSize]) .sheet(isPresented: $showContactConnectionInfo) { if case let .contactConnection(contactConnection) = chat.chatInfo { ContactConnectionInfo(contactConnection: contactConnection) .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) } } .onTapGesture { showContactConnectionInfo = true } } private func deleteContactAlert(_ chatInfo: ChatInfo) -> Alert { Alert( title: Text("Delete contact?"), message: Text("Contact and all messages will be deleted - this cannot be undone!"), primaryButton: .destructive(Text("Delete")) { Task { await deleteChat(chat) } }, secondaryButton: .cancel() ) } private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { Alert( title: Text("Delete group?"), message: deleteGroupAlertMessage(groupInfo), primaryButton: .destructive(Text("Delete")) { Task { await deleteChat(chat) } }, secondaryButton: .cancel() ) } private func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text { groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!") } private func clearChatAlert() -> Alert { Alert( title: Text("Clear conversation?"), message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), primaryButton: .destructive(Text("Clear")) { Task { await clearChat(chat) } }, secondaryButton: .cancel() ) } private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert { Alert( title: Text("Leave group?"), message: Text("You will stop receiving messages from this group. Chat history will be preserved."), primaryButton: .destructive(Text("Leave")) { Task { await leaveGroup(groupInfo.groupId) } }, secondaryButton: .cancel() ) } private func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert { Alert( title: Text("Reject contact request"), message: Text("The sender will NOT be notified"), primaryButton: .destructive(Text("Reject")) { Task { await rejectContactRequest(contactRequest) } }, secondaryButton: .cancel() ) } private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert { Alert( title: Text("Contact is not connected yet!"), message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."), primaryButton: .cancel(), secondaryButton: .destructive(Text("Delete Contact")) { removePendingContact(chat, contact) } ) } private func groupInvitationAcceptedAlert() -> Alert { Alert( title: Text("Joining group"), message: Text("You joined this group. Connecting to inviting group member.") ) } private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert { Alert( title: Text("Delete pending connection"), message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."), primaryButton: .destructive(Text("Delete")) { removePendingContact(chat, contact) }, secondaryButton: .cancel() ) } private func removePendingContact(_ chat: Chat, _ contact: Contact) { Task { do { try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) DispatchQueue.main.async { chatModel.removeChat(contact.id) } } catch let error { logger.error("ChatListNavLink.removePendingContact apiDeleteChat error: \(responseError(error))") } } } private func invalidJSONPreview(_ json: String) -> some View { Text("invalid chat data") .foregroundColor(.red) .padding(4) .frame(height: rowHeights[dynamicTypeSize]) .onTapGesture { showInvalidJSON = true } .sheet(isPresented: $showInvalidJSON) { invalidJSONView(json) .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) } } } func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert { Alert( title: Text("Delete pending connection?"), message: contactConnection.initiated ? Text("The contact you shared this link with will NOT be able to connect!") : Text("The connection you accepted will be cancelled!"), primaryButton: .destructive(Text("Delete")) { Task { do { try await apiDeleteChat(type: .contactConnection, id: contactConnection.apiId) await MainActor.run { ChatModel.shared.removeChat(contactConnection.id) success() } } catch let error { await MainActor.run { showError(getErrorAlert(error, "Error deleting connection")) } } } }, secondaryButton: .cancel() ) } func joinGroup(_ groupId: Int64) { Task { logger.debug("joinGroup") do { let r = try await apiJoinGroup(groupId) switch r { case let .joined(groupInfo): await MainActor.run { ChatModel.shared.updateGroup(groupInfo) } case .invitationRemoved: AlertManager.shared.showAlertMsg(title: "Invitation expired!", message: "Group invitation is no longer valid, it was removed by sender.") await deleteGroup() case .groupNotFound: AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.") await deleteGroup() } } catch let error { let a = getErrorAlert(error, "Error joining group") AlertManager.shared.showAlertMsg(title: a.title, message: a.message) } func deleteGroup() async { do { // TODO this API should update chat item with the invitation as well try await apiDeleteChat(type: .group, id: groupId) await MainActor.run { ChatModel.shared.removeChat("#\(groupId)") } } catch { logger.error("apiDeleteChat error: \(responseError(error))") } } } } struct ErrorAlert { var title: LocalizedStringKey var message: LocalizedStringKey } func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert { switch error as? ChatResponse { case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.") case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))): return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.") default: return ErrorAlert(title: title, message: "Error: \(responseError(error))") } } struct ChatListNavLink_Previews: PreviewProvider { static var previews: some View { @State var chatId: String? = "@1" return Group { ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] )) ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] )) ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.contactRequest, chatItems: [] )) } .previewLayout(.fixed(width: 360, height: 82)) } }