diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e33df24e06..7ad8e38692 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -340,7 +340,13 @@ func loadChat(chat: Chat, search: String = "") { m.chatItemStatuses = [:] im.reversedChatItems = [] let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) - m.updateChatInfo(chat.chatInfo) + if case let .direct(contact) = chat.chatInfo, !cInfo.chatDeleted, chat.chatInfo.chatDeleted { + var updatedContact = contact + updatedContact.chatDeleted = false + m.updateContact(updatedContact) + } else { + m.updateChatInfo(chat.chatInfo) + } im.reversedChatItems = chat.chatItems.reversed() } catch let error { logger.error("loadChat error: \(responseError(error))") @@ -761,22 +767,38 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co return (nil, alert) } -func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws { +func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws { let chatId = type.rawValue + id.description DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) } defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } } - let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false) + let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) if case .direct = type, case .contactDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return } if case .group = type, case .groupDeletedUser = r { return } throw r } -func deleteChat(_ chat: Chat, notify: Bool? = nil) async { +func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact { + let type: ChatType = .direct + let chatId = type.rawValue + id.description + if case .full = chatDeleteMode { + DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) } + } + defer { + if case .full = chatDeleteMode { + DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } + } + } + let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) + if case let .contactDeleted(_, contact) = r { return contact } + throw r +} + +func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async { do { let cInfo = chat.chatInfo - try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify) - DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) } + try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode) + await MainActor.run { ChatModel.shared.removeChat(cInfo.id) } } catch let error { logger.error("deleteChat apiDeleteChat error: \(responseError(error))") AlertManager.shared.showAlertMsg( @@ -786,6 +808,39 @@ func deleteChat(_ chat: Chat, notify: Bool? = nil) async { } } +func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async -> Alert? { + do { + let cInfo = chat.chatInfo + let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode) + await MainActor.run { + switch chatDeleteMode { + case .full: + ChatModel.shared.removeChat(cInfo.id) + case .entity: + ChatModel.shared.removeChat(cInfo.id) + ChatModel.shared.addChat(Chat( + chatInfo: .direct(contact: ct), + chatItems: chat.chatItems + )) + case .messages: + ChatModel.shared.removeChat(cInfo.id) + ChatModel.shared.addChat(Chat( + chatInfo: .direct(contact: ct), + chatItems: [] + )) + } + } + } catch let error { + logger.error("deleteContactChat apiDeleteContact error: \(responseError(error))") + return mkAlert( + title: "Error deleting chat!", + message: "Error: \(responseError(error))" + ) + } + return nil +} + + func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo { let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false) if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo } @@ -1114,10 +1169,17 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? { func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async { if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) { let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) - DispatchQueue.main.async { + await MainActor.run { ChatModel.shared.replaceChat(contactRequest.id, chat) ChatModel.shared.setContactNetworkStatus(contact, .connected) } + if contact.sndReady { + DispatchQueue.main.async { + dismissAllSheets(animated: true) { + ChatModel.shared.chatId = chat.id + } + } + } } } @@ -1713,6 +1775,11 @@ func processReceivedMsg(_ res: ChatResponse) async { let cItem = aChatItem.chatItem await MainActor.run { if active(user) { + if case let .direct(contact) = cInfo, contact.chatDeleted { + var updatedContact = contact + updatedContact.chatDeleted = false + m.updateContact(updatedContact) + } m.addChatItem(cInfo, cItem) } else if cItem.isRcvNew && cInfo.ntfsEnabled { m.increaseUnreadCounter(user: user) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 4d45db4700..ed1b377723 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -94,17 +94,20 @@ struct ChatInfoView: View { @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat @State var contact: Contact - @Binding var connectionStats: ConnectionStats? - @Binding var customUserProfile: Profile? @State var localAlias: String - @Binding var connectionCode: String? + var onSearch: () -> Void + @State private var connectionStats: ConnectionStats? = nil + @State private var customUserProfile: Profile? = nil + @State private var connectionCode: String? = nil @FocusState private var aliasTextFieldFocused: Bool @State private var alert: ChatInfoViewAlert? = nil - @State private var showDeleteContactActionSheet = false + @State private var actionSheet: SomeActionSheet? = nil + @State private var sheet: SomeSheet? = nil + @State private var showConnectContactViaAddressDialog = false @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false - + enum ChatInfoViewAlert: Identifiable { case clearChatAlert case networkStatusAlert @@ -112,6 +115,7 @@ struct ChatInfoView: View { case abortSwitchAddressAlert case syncConnectionForceAlert case queueInfo(info: String) + case someAlert(alert: SomeAlert) case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { @@ -122,11 +126,12 @@ struct ChatInfoView: View { case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" case let .queueInfo(info): return "queueInfo \(info)" + case let .someAlert(alert): return "chatInfoSomeAlert \(alert.id)" case let .error(title, _): return "error \(title)" } } } - + var body: some View { NavigationView { List { @@ -136,13 +141,28 @@ struct ChatInfoView: View { .onTapGesture { aliasTextFieldFocused = false } - + Group { localAliasTextEdit() } .listRowBackground(Color.clear) .listRowSeparator(.hidden) - + + HStack { + Spacer() + searchButton() + Spacer() + AudioCallButton(chat: chat, contact: contact, showAlert: { alert = .someAlert(alert: $0) }) + Spacer() + VideoButton(chat: chat, contact: contact, showAlert: { alert = .someAlert(alert: $0) }) + Spacer() + muteButton() + Spacer() + } + .padding(.horizontal) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + if let customUserProfile = customUserProfile { Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { HStack { @@ -153,7 +173,7 @@ struct ChatInfoView: View { } } } - + Section { Group { if let code = connectionCode { verifyCodeButton(code) } @@ -173,14 +193,18 @@ struct ChatInfoView: View { } label: { Label("Chat theme", systemImage: "photo") } + // } else if developerTools { + // synchronizeConnectionButtonForce() + // } } - + .disabled(!contact.ready || !contact.active) + if let conn = contact.activeConn { Section { infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard") } } - + if let contactLink = contact.contactLink { Section { SimpleXLinkQRCode(uri: contactLink) @@ -197,7 +221,7 @@ struct ChatInfoView: View { .foregroundColor(theme.colors.secondary) } } - + if contact.ready && contact.active { Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { networkStatusRow() @@ -226,12 +250,12 @@ struct ChatInfoView: View { } } } - + Section { clearChatButton() deleteContactButton() } - + if developerTools { Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { infoRow("Local name", chat.chatInfo.localDisplayName) @@ -260,6 +284,24 @@ struct ChatInfoView: View { sendReceiptsUserDefault = currentUser.sendRcptsContacts } sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) + + + Task { + do { + let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId) + let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId) + await MainActor.run { + connectionStats = stats + customUserProfile = profile + connectionCode = code + if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode { + chat.chatInfo = .direct(contact: ct) + } + } + } catch let error { + logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))") + } + } } .alert(item: $alert) { alertItem in switch(alertItem) { @@ -269,31 +311,21 @@ struct ChatInfoView: View { case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) }) case let .queueInfo(info): return queueInfoAlert(info) + case let .someAlert(a): return a.alert case let .error(title, error): return mkAlert(title: title, message: error) } } - .actionSheet(isPresented: $showDeleteContactActionSheet) { - if contact.sndReady && contact.active { - return ActionSheet( - title: Text("Delete contact?\nThis cannot be undone!"), - buttons: [ - .destructive(Text("Delete and notify contact")) { deleteContact(notify: true) }, - .destructive(Text("Delete")) { deleteContact(notify: false) }, - .cancel() - ] - ) + .actionSheet(item: $actionSheet) { $0.actionSheet } + .sheet(item: $sheet) { + if #available(iOS 16.0, *) { + $0.content + .presentationDetents([.fraction(0.4)]) } else { - return ActionSheet( - title: Text("Delete contact?\nThis cannot be undone!"), - buttons: [ - .destructive(Text("Delete")) { deleteContact() }, - .cancel() - ] - ) + $0.content } } } - + private func contactInfoHeader() -> some View { VStack { let cInfo = chat.chatInfo @@ -328,7 +360,7 @@ struct ChatInfoView: View { } .frame(maxWidth: .infinity, alignment: .center) } - + private func localAliasTextEdit() -> some View { TextField("Set contact name…", text: $localAlias) .disableAutocorrection(true) @@ -345,7 +377,7 @@ struct ChatInfoView: View { .multilineTextAlignment(.center) .foregroundColor(theme.colors.secondary) } - + private func setContactAlias() { Task { do { @@ -360,6 +392,26 @@ struct ChatInfoView: View { } } + private func searchButton() -> some View { + InfoViewActionButtonLayout(image: "magnifyingglass", title: "search") + .onTapGesture { + dismiss() + onSearch() + } + .disabled(!contact.ready || chat.chatItems.isEmpty) + } + + private func muteButton() -> some View { + InfoViewActionButtonLayout( + image: chat.chatInfo.ntfsEnabled ? "speaker.slash" : "speaker.wave.2", + title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute" + ) + .onTapGesture { + toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) + } + .disabled(!contact.ready || !contact.active) + } + private func verifyCodeButton(_ code: String) -> some View { NavigationLink { VerifyCodeView( @@ -389,7 +441,7 @@ struct ChatInfoView: View { ) } } - + private func contactPreferencesButton() -> some View { NavigationLink { ContactPreferencesView( @@ -404,7 +456,7 @@ struct ChatInfoView: View { Label("Contact preferences", systemImage: "switch.2") } } - + private func sendReceiptsOption() -> some View { Picker(selection: $sendReceipts) { ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in @@ -418,13 +470,13 @@ struct ChatInfoView: View { setSendReceipts() } } - + private func setSendReceipts() { var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults chatSettings.sendRcpts = sendReceipts.bool() updateChatSettings(chat, chatSettings: chatSettings) } - + private func synchronizeConnectionButton() -> some View { Button { syncContactConnection(force: false) @@ -433,7 +485,7 @@ struct ChatInfoView: View { .foregroundColor(.orange) } } - + private func synchronizeConnectionButtonForce() -> some View { Button { alert = .syncConnectionForceAlert @@ -442,7 +494,7 @@ struct ChatInfoView: View { .foregroundColor(.red) } } - + private func networkStatusRow() -> some View { HStack { Text("Network status") @@ -455,23 +507,30 @@ struct ChatInfoView: View { serverImage() } } - + private func serverImage() -> some View { let status = chatModel.contactNetworkStatus(contact) return Image(systemName: status.imageName) .foregroundColor(status == .connected ? .green : theme.colors.secondary) .font(.system(size: 12)) } - + private func deleteContactButton() -> some View { Button(role: .destructive) { - showDeleteContactActionSheet = true + deleteContactDialog( + chat, + contact, + dismissToChatList: true, + showAlert: { alert = .someAlert(alert: $0) }, + showActionSheet: { actionSheet = $0 }, + showSheetContent: { sheet = $0 } + ) } label: { - Label("Delete contact", systemImage: "trash") + Label("Delete contact", systemImage: "person.badge.minus") .foregroundColor(Color.red) } } - + private func clearChatButton() -> some View { Button() { alert = .clearChatAlert @@ -480,26 +539,7 @@ struct ChatInfoView: View { .foregroundColor(Color.orange) } } - - private func deleteContact(notify: Bool? = nil) { - Task { - do { - try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify) - await MainActor.run { - dismiss() - chatModel.chatId = nil - chatModel.removeChat(chat.chatInfo.id) - } - } catch let error { - logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))") - let a = getErrorAlert(error, "Error deleting contact") - await MainActor.run { - alert = .error(title: a.title, error: a.message) - } - } - } - } - + private func clearChatAlert() -> Alert { Alert( title: Text("Clear conversation?"), @@ -513,14 +553,14 @@ struct ChatInfoView: View { secondaryButton: .cancel() ) } - + private func networkStatusAlert() -> Alert { Alert( title: Text("Network status"), message: Text(chatModel.contactNetworkStatus(contact).statusExplanation) ) } - + private func switchContactAddress() { Task { do { @@ -539,7 +579,7 @@ struct ChatInfoView: View { } } } - + private func abortSwitchContactAddress() { Task { do { @@ -557,7 +597,7 @@ struct ChatInfoView: View { } } } - + private func syncContactConnection(force: Bool) { Task { do { @@ -578,6 +618,144 @@ struct ChatInfoView: View { } } +struct AudioCallButton: View { + var chat: Chat + var contact: Contact + var showAlert: (SomeAlert) -> Void + + var body: some View { + CallButton( + chat: chat, + contact: contact, + image: "phone", + title: "call", + mediaType: .audio, + showAlert: showAlert + ) + } +} + +struct VideoButton: View { + var chat: Chat + var contact: Contact + var showAlert: (SomeAlert) -> Void + + var body: some View { + CallButton( + chat: chat, + contact: contact, + image: "video", + title: "video", + mediaType: .video, + showAlert: showAlert + ) + } +} + +private struct CallButton: View { + var chat: Chat + var contact: Contact + var image: String + var title: LocalizedStringKey + var mediaType: CallMediaType + var showAlert: (SomeAlert) -> Void + + var body: some View { + let canCall = contact.ready && contact.active && chat.chatInfo.featureEnabled(.calls) && ChatModel.shared.activeCall == nil + + InfoViewActionButtonLayout(image: image, title: title, disabledLook: !canCall) + .onTapGesture { + if canCall { + CallController.shared.startCall(contact, mediaType) + } else if contact.nextSendGrpInv { + showAlert(SomeAlert( + alert: mkAlert( + title: "Can't call contact", + message: "Send message to enable calls." + ), + id: "can't call contact, send message" + )) + } else if !contact.active { + showAlert(SomeAlert( + alert: mkAlert( + title: "Can't call contact", + message: "Contact is deleted." + ), + id: "can't call contact, contact deleted" + )) + } else if !contact.ready { + showAlert(SomeAlert( + alert: mkAlert( + title: "Can't call contact", + message: "Connecting to contact, please wait or check later!" + ), + id: "can't call contact, contact not ready" + )) + } else if !chat.chatInfo.featureEnabled(.calls) { + switch chat.chatInfo.showEnableCallsAlert { + case .userEnable: + showAlert(SomeAlert( + alert: Alert( + title: Text("Allow calls?"), + message: Text("You need to allow your contact to call to be able to call them."), + primaryButton: .default(Text("Allow")) { + allowFeatureToContact(contact, .calls) + }, + secondaryButton: .cancel() + ), + id: "allow calls" + )) + case .askContact: + showAlert(SomeAlert( + alert: mkAlert( + title: "Calls prohibited!", + message: "Please ask your contact to enable calls." + ), + id: "calls prohibited, ask contact" + )) + case .other: + showAlert(SomeAlert( + alert: mkAlert( + title: "Calls prohibited!", + message: "Please check yours and your contact preferences." + ) + , id: "calls prohibited, other" + )) + } + } else { + showAlert(SomeAlert( + alert: mkAlert(title: "Can't call contact"), + id: "can't call contact" + )) + } + } + .disabled(ChatModel.shared.activeCall != nil) + } +} + +struct InfoViewActionButtonLayout: View { + var image: String + var title: LocalizedStringKey + var disabledLook: Bool = false + + var body: some View { + VStack(spacing: 4) { + Image(systemName: image) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + Text(title) + .font(.caption) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.accentColor) + .background(Color(.secondarySystemGroupedBackground)) + .cornerRadius(12.0) + .frame(width: 82, height: 56) + .disabled(disabledLook) + } +} + struct ChatWallpaperEditorSheet: View { @Environment(\.dismiss) var dismiss @EnvironmentObject var theme: AppTheme @@ -763,15 +941,222 @@ func queueInfoAlert(_ info: String) -> Alert { ) } +func deleteContactDialog( + _ chat: Chat, + _ contact: Contact, + dismissToChatList: Bool, + showAlert: @escaping (SomeAlert) -> Void, + showActionSheet: @escaping (SomeActionSheet) -> Void, + showSheetContent: @escaping (SomeSheet) -> Void +) { + if contact.sndReady && contact.active && !contact.chatDeleted { + deleteContactOrConversationDialog(chat, contact, dismissToChatList, showAlert, showActionSheet, showSheetContent) + } else if contact.sndReady && contact.active && contact.chatDeleted { + deleteContactWithoutConversation(chat, contact, dismissToChatList, showAlert, showActionSheet) + } else { // !(contact.sndReady && contact.active) + deleteNotReadyContact(chat, contact, dismissToChatList, showAlert, showActionSheet) + } +} + +private func deleteContactOrConversationDialog( + _ chat: Chat, + _ contact: Contact, + _ dismissToChatList: Bool, + _ showAlert: @escaping (SomeAlert) -> Void, + _ showActionSheet: @escaping (SomeActionSheet) -> Void, + _ showSheetContent: @escaping (SomeSheet) -> Void +) { + showActionSheet(SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Delete contact?"), + buttons: [ + .destructive(Text("Only delete conversation")) { + deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .messages, dismissToChatList, showAlert) + }, + .destructive(Text("Delete contact")) { + showSheetContent(SomeSheet( + content: { AnyView( + DeleteActiveContactDialog( + chat: chat, + contact: contact, + dismissToChatList: dismissToChatList, + showAlert: showAlert + ) + ) }, + id: "DeleteActiveContactDialog" + )) + }, + .cancel() + ] + ), + id: "deleteContactOrConversationDialog" + )) +} + +private func deleteContactMaybeErrorAlert( + _ chat: Chat, + _ contact: Contact, + chatDeleteMode: ChatDeleteMode, + _ dismissToChatList: Bool, + _ showAlert: @escaping (SomeAlert) -> Void +) { + Task { + let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode) + if let alert = alert_ { + showAlert(SomeAlert(alert: alert, id: "deleteContactMaybeErrorAlert, error")) + } else { + if dismissToChatList { + await MainActor.run { + ChatModel.shared.chatId = nil + } + DispatchQueue.main.async { + dismissAllSheets(animated: true) { + if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() { + AlertManager.shared.showAlert(deleteConversationNotice(contact)) + } else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() { + AlertManager.shared.showAlert(deleteContactNotice(contact)) + } + } + } + } else { + if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() { + showAlert(SomeAlert(alert: deleteConversationNotice(contact), id: "deleteContactMaybeErrorAlert, deleteConversationNotice")) + } else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() { + showAlert(SomeAlert(alert: deleteContactNotice(contact), id: "deleteContactMaybeErrorAlert, deleteContactNotice")) + } + } + } + } +} + +private func deleteConversationNotice(_ contact: Contact) -> Alert { + return Alert( + title: Text("Conversation deleted!"), + message: Text("You can still send messages to \(contact.displayName) from the Deleted chats."), + primaryButton: .default(Text("Don't show again")) { + showDeleteConversationNoticeDefault.set(false) + }, + secondaryButton: .default(Text("Ok")) + ) +} + +private func deleteContactNotice(_ contact: Contact) -> Alert { + return Alert( + title: Text("Contact deleted!"), + message: Text("You can still view conversation with \(contact.displayName) in the list of chats."), + primaryButton: .default(Text("Don't show again")) { + showDeleteContactNoticeDefault.set(false) + }, + secondaryButton: .default(Text("Ok")) + ) +} + +enum ContactDeleteMode { + case full + case entity + + public func toChatDeleteMode(notify: Bool) -> ChatDeleteMode { + switch self { + case .full: .full(notify: notify) + case .entity: .entity(notify: notify) + } + } +} + +struct DeleteActiveContactDialog: View { + @Environment(\.dismiss) var dismiss + @EnvironmentObject var theme: AppTheme + var chat: Chat + var contact: Contact + var dismissToChatList: Bool + var showAlert: (SomeAlert) -> Void + @State private var keepConversation = false + + var body: some View { + NavigationView { + List { + Section { + Toggle("Keep conversation", isOn: $keepConversation) + + Button(role: .destructive) { + dismiss() + deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false), dismissToChatList, showAlert) + } label: { + Text("Delete without notification") + } + + Button(role: .destructive) { + dismiss() + deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true), dismissToChatList, showAlert) + } label: { + Text("Delete and notify contact") + } + } footer: { + Text("Contact will be deleted - this cannot be undone!") + .foregroundColor(theme.colors.secondary) + } + } + .modifier(ThemedBackground(grouped: true)) + } + } + + var contactDeleteMode: ContactDeleteMode { + keepConversation ? .entity : .full + } +} + +private func deleteContactWithoutConversation( + _ chat: Chat, + _ contact: Contact, + _ dismissToChatList: Bool, + _ showAlert: @escaping (SomeAlert) -> Void, + _ showActionSheet: @escaping (SomeActionSheet) -> Void +) { + showActionSheet(SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Confirm contact deletion?"), + buttons: [ + .destructive(Text("Delete and notify contact")) { + deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: true), dismissToChatList, showAlert) + }, + .destructive(Text("Delete without notification")) { + deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert) + }, + .cancel() + ] + ), + id: "deleteContactWithoutConversation" + )) +} + +private func deleteNotReadyContact( + _ chat: Chat, + _ contact: Contact, + _ dismissToChatList: Bool, + _ showAlert: @escaping (SomeAlert) -> Void, + _ showActionSheet: @escaping (SomeActionSheet) -> Void +) { + showActionSheet(SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Confirm contact deletion?"), + buttons: [ + .destructive(Text("Confirm")) { + deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert) + }, + .cancel() + ] + ), + id: "deleteNotReadyContact" + )) +} + struct ChatInfoView_Previews: PreviewProvider { static var previews: some View { ChatInfoView( chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), contact: Contact.sampleData, - connectionStats: Binding.constant(nil), - customUserProfile: Binding.constant(nil), localAlias: "", - connectionCode: Binding.constant(nil) + onSearch: {} ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 9a55060c7d..b047451991 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -179,32 +179,18 @@ struct ChatView: View { } else if case let .direct(contact) = cInfo { Button { Task { - do { - let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId) - let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId) - await MainActor.run { - connectionStats = stats - customUserProfile = profile - connectionCode = code - if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode { - chat.chatInfo = .direct(contact: ct) - } - } - } catch let error { - logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))") - } - await MainActor.run { showChatInfoSheet = true } + showChatInfoSheet = true } } label: { ChatInfoToolbar(chat: chat) } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { - connectionStats = nil - customUserProfile = nil - connectionCode = nil - theme = buildTheme() - }) { - ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: $customUserProfile, localAlias: chat.chatInfo.localAlias, connectionCode: $connectionCode) + .appSheet(isPresented: $showChatInfoSheet) { + ChatInfoView( + chat: chat, + contact: contact, + localAlias: chat.chatInfo.localAlias, + onSearch: { focusSearch() } + ) } } else if case let .group(groupInfo) = cInfo { Button { @@ -222,7 +208,8 @@ struct ChatView: View { chat.chatInfo = .group(groupInfo: gInfo) chat.created = Date.now } - ) + ), + onSearch: { focusSearch() } ) } } else if case .local = cInfo { @@ -564,14 +551,18 @@ struct ChatView: View { private func searchButton() -> some View { Button { - searchMode = true - searchFocussed = true - searchText = "" + focusSearch() } label: { Label("Search", systemImage: "magnifyingglass") } } + private func focusSearch() { + searchMode = true + searchFocussed = true + searchText = "" + } + private func addMembersButton() -> some View { Button { if case let .group(gInfo) = chat.chatInfo { diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index dc867b026f..189ab95494 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -47,14 +47,13 @@ struct AddGroupMembersViewCommon: View { var body: some View { if creatingGroup { - NavigationView { - addGroupMembersView() - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button ("Skip") { addedMembersCb(selectedContacts) } - } + addGroupMembersView() + .navigationBarBackButtonHidden() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button ("Skip") { addedMembersCb(selectedContacts) } } - } + } } else { addGroupMembersView() } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 2b8656bfa4..56af0075ca 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -17,10 +17,12 @@ struct GroupChatInfoView: View { @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat @Binding var groupInfo: GroupInfo + var onSearch: () -> Void @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? @State private var groupLinkMemberRole: GroupMemberRole = .member - @State private var showAddMembersSheet: Bool = false + @State private var groupLinkNavLinkActive: Bool = false + @State private var addMembersNavLinkActive: Bool = false @State private var connectionStats: ConnectionStats? @State private var connectionCode: String? @State private var sendReceipts = SendReceipts.userDefault(true) @@ -69,6 +71,21 @@ struct GroupChatInfoView: View { groupInfoHeader() .listRowBackground(Color.clear) + HStack { + Spacer() + searchButton() + if groupInfo.canAddMembers { + Spacer() + addMembersActionButton() + } + Spacer() + muteButton() + Spacer() + } + .padding(.horizontal) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + Section { if groupInfo.canEdit { editGroupButton() @@ -198,24 +215,82 @@ struct GroupChatInfoView: View { .frame(maxWidth: .infinity, alignment: .center) } + private func searchButton() -> some View { + InfoViewActionButtonLayout(image: "magnifyingglass", title: "search") + .onTapGesture { + dismiss() + onSearch() + } + .disabled(!groupInfo.ready || chat.chatItems.isEmpty) + } + + @ViewBuilder private func addMembersActionButton() -> some View { + if chat.chatInfo.incognito { + ZStack { + InfoViewActionButtonLayout(image: "link.badge.plus", title: "invite") + .onTapGesture { + groupLinkNavLinkActive = true + } + + NavigationLink(isActive: $groupLinkNavLinkActive) { + groupLinkDestinationView() + } label: { + EmptyView() + } + .hidden() + } + .disabled(!groupInfo.ready) + } else { + ZStack { + InfoViewActionButtonLayout(image: "person.badge.plus", title: "invite") + .onTapGesture { + addMembersNavLinkActive = true + } + + NavigationLink(isActive: $addMembersNavLinkActive) { + addMembersDestinationView() + } label: { + EmptyView() + } + .hidden() + } + .disabled(!groupInfo.ready) + } + } + + private func muteButton() -> some View { + InfoViewActionButtonLayout( + image: chat.chatInfo.ntfsEnabled ? "speaker.slash" : "speaker.wave.2", + title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute" + ) + .onTapGesture { + toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) + } + .disabled(!groupInfo.ready) + } + private func addMembersButton() -> some View { NavigationLink { - AddGroupMembersView(chat: chat, groupInfo: groupInfo) - .onAppear { - searchFocussed = false - Task { - let groupMembers = await apiListMembers(groupInfo.groupId) - await MainActor.run { - chatModel.groupMembers = groupMembers.map { GMember.init($0) } - chatModel.populateGroupMembersIndexes() - } - } - } + addMembersDestinationView() } label: { Label("Invite members", systemImage: "plus") } } + private func addMembersDestinationView() -> some View { + AddGroupMembersView(chat: chat, groupInfo: groupInfo) + .onAppear { + searchFocussed = false + Task { + let groupMembers = await apiListMembers(groupInfo.groupId) + await MainActor.run { + chatModel.groupMembers = groupMembers.map { GMember.init($0) } + chatModel.populateGroupMembersIndexes() + } + } + } + } + private struct MemberRowView: View { var groupInfo: GroupInfo @ObservedObject var groupMember: GMember @@ -352,16 +427,7 @@ struct GroupChatInfoView: View { private func groupLinkButton() -> some View { NavigationLink { - GroupLinkView( - groupId: groupInfo.groupId, - groupLink: $groupLink, - groupLinkMemberRole: $groupLinkMemberRole, - showTitle: false, - creatingGroup: false - ) - .navigationBarTitle("Group link") - .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) + groupLinkDestinationView() } label: { if groupLink == nil { Label("Create group link", systemImage: "link.badge.plus") @@ -371,6 +437,19 @@ struct GroupChatInfoView: View { } } + private func groupLinkDestinationView() -> some View { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: false, + creatingGroup: false + ) + .navigationBarTitle("Group link") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } + private func editGroupButton() -> some View { NavigationLink { GroupProfileView( @@ -577,7 +656,8 @@ struct GroupChatInfoView_Previews: PreviewProvider { static var previews: some View { GroupChatInfoView( chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), - groupInfo: Binding.constant(GroupInfo.sampleData) + groupInfo: Binding.constant(GroupInfo.sampleData), + onSearch: {} ) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 93a8be04f4..39288e2d52 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -34,14 +34,13 @@ struct GroupLinkView: View { var body: some View { if creatingGroup { - NavigationView { - groupLinkView() - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button ("Continue") { linkCreatedCb?() } - } + groupLinkView() + .navigationBarBackButtonHidden() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button ("Continue") { linkCreatedCb?() } } - } + } } else { groupLinkView() } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 5bbaf49d8e..61e840ba85 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -37,6 +37,7 @@ struct GroupMemberInfoView: View { case syncConnectionForceAlert case planAndConnectAlert(alert: PlanAndConnectAlert) case queueInfo(info: String) + case someAlert(alert: SomeAlert) case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { @@ -52,6 +53,7 @@ struct GroupMemberInfoView: View { case .syncConnectionForceAlert: return "syncConnectionForceAlert" case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)" case let .queueInfo(info): return "queueInfo \(info)" + case let .someAlert(alert): return "someAlert \(alert.id)" case let .error(title, _): return "error \(title)" } } @@ -65,10 +67,11 @@ struct GroupMemberInfoView: View { } } - private func knownDirectChat(_ contactId: Int64) -> Chat? { + private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? { if let chat = chatModel.getContactChat(contactId), - chat.chatInfo.contact?.directOrUsed == true { - return chat + let contact = chat.chatInfo.contact, + contact.directOrUsed == true { + return (chat, contact) } else { return nil } @@ -80,21 +83,18 @@ struct GroupMemberInfoView: View { List { groupMemberInfoHeader(member) .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + + infoActionButtons(member) + .padding(.horizontal) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) if member.memberActive { Section { - if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) { - knownDirectChatButton(chat) - } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { - if let contactId = member.memberContactId { - newDirectChatButton(contactId) - } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false { - createMemberContactButton() - } - } if let code = connectionCode { verifyCodeButton(code) } if let connStats = connectionStats, - connStats.ratchetSyncAllowed { + connStats.ratchetSyncAllowed { synchronizeConnectionButton() } // } else if developerTools { @@ -237,6 +237,7 @@ struct GroupMemberInfoView: View { case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true) case let .queueInfo(info): return queueInfoAlert(info) + case let .someAlert(a): return a.alert case let .error(title, error): return mkAlert(title: title, message: error) } } @@ -249,6 +250,66 @@ struct GroupMemberInfoView: View { .modifier(ThemedBackground(grouped: true)) } + func infoActionButtons(_ member: GroupMember) -> some View { + HStack { + if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) { + Spacer() + knownDirectChatButton(chat) + Spacer() + AudioCallButton(chat: chat, contact: contact, showAlert: { alert = .someAlert(alert: $0) }) + Spacer() + VideoButton(chat: chat, contact: contact, showAlert: { alert = .someAlert(alert: $0) }) + Spacer() + } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { + if let contactId = member.memberContactId { + Spacer() + newDirectChatButton(contactId) + } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false { + Spacer() + createMemberContactButton() + } + Spacer() + InfoViewActionButtonLayout(image: "phone", title: "call", disabledLook: true) + .onTapGesture { showSendMessageToEnableCallsAlert() } + Spacer() + InfoViewActionButtonLayout(image: "video", title: "video", disabledLook: true) + .onTapGesture { showSendMessageToEnableCallsAlert() } + Spacer() + } else { // no known contact chat && directMessages are off + Spacer() + InfoViewActionButtonLayout(image: "message", title: "message", disabledLook: true) + .onTapGesture { showDirectMessagesProhibitedAlert("Can't message member") } + Spacer() + InfoViewActionButtonLayout(image: "phone", title: "call", disabledLook: true) + .onTapGesture { showDirectMessagesProhibitedAlert("Can't call member") } + Spacer() + InfoViewActionButtonLayout(image: "video", title: "video", disabledLook: true) + .onTapGesture { showDirectMessagesProhibitedAlert("Can't call member") } + Spacer() + } + } + } + + func showSendMessageToEnableCallsAlert() { + alert = .someAlert(alert: SomeAlert( + alert: mkAlert( + title: "Can't call member", + message: "Send message to enable calls." + ), + id: "can't call member, send message" + )) + } + + func showDirectMessagesProhibitedAlert(_ title: LocalizedStringKey) { + alert = .someAlert(alert: SomeAlert( + alert: mkAlert( + title: title, + message: "Direct messages between members are prohibited in this group." + ), + id: "can't message member, direct messages prohibited" + )) + } + func connectViaAddressButton(_ contactLink: String) -> some View { Button { planAndConnect( @@ -264,58 +325,55 @@ struct GroupMemberInfoView: View { } func knownDirectChatButton(_ chat: Chat) -> some View { - Button { - dismissAllSheets(animated: true) - DispatchQueue.main.async { - chatModel.chatId = chat.id - } - } label: { - Label("Send direct message", systemImage: "message") - } - } - - func newDirectChatButton(_ contactId: Int64) -> some View { - Button { - do { - let chat = try apiGetChat(type: .direct, id: contactId) - chatModel.addChat(chat) + InfoViewActionButtonLayout(image: "message", title: "message") + .onTapGesture { dismissAllSheets(animated: true) DispatchQueue.main.async { chatModel.chatId = chat.id } - } catch let error { - logger.error("openDirectChatButton apiGetChat error: \(responseError(error))") } - } label: { - Label("Send direct message", systemImage: "message") - } + } + + func newDirectChatButton(_ contactId: Int64) -> some View { + InfoViewActionButtonLayout(image: "message", title: "message") + .onTapGesture { + do { + let chat = try apiGetChat(type: .direct, id: contactId) + chatModel.addChat(chat) + dismissAllSheets(animated: true) + DispatchQueue.main.async { + chatModel.chatId = chat.id + } + } catch let error { + logger.error("openDirectChatButton apiGetChat error: \(responseError(error))") + } + } } func createMemberContactButton() -> some View { - Button { - progressIndicator = true - Task { - do { - let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId) - await MainActor.run { - progressIndicator = false - chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) - dismissAllSheets(animated: true) - chatModel.chatId = memberContact.id - chatModel.setContactNetworkStatus(memberContact, .connected) - } - } catch let error { - logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))") - let a = getErrorAlert(error, "Error creating member contact") - await MainActor.run { - progressIndicator = false - alert = .error(title: a.title, error: a.message) + InfoViewActionButtonLayout(image: "message", title: "message") + .onTapGesture { + progressIndicator = true + Task { + do { + let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId) + await MainActor.run { + progressIndicator = false + chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) + dismissAllSheets(animated: true) + chatModel.chatId = memberContact.id + chatModel.setContactNetworkStatus(memberContact, .connected) + } + } catch let error { + logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))") + let a = getErrorAlert(error, "Error creating member contact") + await MainActor.run { + progressIndicator = false + alert = .error(title: a.title, error: a.message) + } } } } - } label: { - Label("Send direct message", systemImage: "message") - } } private func groupMemberInfoHeader(_ mem: GroupMember) -> some View { diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift index 2435c9a4f5..7562aa1b9e 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -11,7 +11,6 @@ import SwiftUI struct ChatHelp: View { @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool - @State private var newChatMenuOption: NewChatMenuOption? = nil var body: some View { ScrollView { chatHelp() } @@ -39,7 +38,8 @@ struct ChatHelp: View { HStack(spacing: 8) { Text("Tap button ") - NewChatMenuButton(newChatMenuOption: $newChatMenuOption) + // TODO: Check this. + NewChatMenuButton() Text("above, then choose:") } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index add364d9c9..0668d64441 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -49,7 +49,9 @@ struct ChatListNavLink: View { @State private var showJoinGroupDialog = false @State private var showContactConnectionInfo = false @State private var showInvalidJSON = false - @State private var showDeleteContactActionSheet = false + @State private var alert: SomeAlert? = nil + @State private var actionSheet: SomeActionSheet? = nil + @State private var sheet: SomeSheet? = nil @State private var showConnectContactViaAddressDialog = false @State private var inProgress = false @State private var progressByTimeout = false @@ -83,15 +85,22 @@ struct ChatListNavLink: View { } } } - + @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { Group { - if contact.activeConn == nil && contact.profile.contactLink != nil { + if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) .frame(height: dynamicRowHeight) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { - showDeleteContactActionSheet = true + deleteContactDialog( + chat, + contact, + dismissToChatList: false, + showAlert: { alert = $0 }, + showActionSheet: { actionSheet = $0 }, + showSheetContent: { sheet = $0 } + ) } label: { Label("Delete", systemImage: "trash") } @@ -118,11 +127,14 @@ struct ChatListNavLink: View { clearChatButton() } Button { - if contact.sndReady || !contact.active { - showDeleteContactActionSheet = true - } else { - AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact)) - } + deleteContactDialog( + chat, + contact, + dismissToChatList: false, + showAlert: { alert = $0 }, + showActionSheet: { actionSheet = $0 }, + showSheetContent: { sheet = $0 } + ) } label: { Label("Delete", systemImage: "trash") } @@ -131,24 +143,14 @@ struct ChatListNavLink: View { .frame(height: dynamicRowHeight) } } - .actionSheet(isPresented: $showDeleteContactActionSheet) { - if contact.sndReady && contact.active { - return ActionSheet( - title: Text("Delete contact?\nThis cannot be undone!"), - buttons: [ - .destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } }, - .destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } }, - .cancel() - ] - ) + .alert(item: $alert) { $0.alert } + .actionSheet(item: $actionSheet) { $0.actionSheet } + .sheet(item: $sheet) { + if #available(iOS 16.0, *) { + $0.content + .presentationDetents([.fraction(0.4)]) } else { - return ActionSheet( - title: Text("Delete contact?\nThis cannot be undone!"), - buttons: [ - .destructive(Text("Delete")) { Task { await deleteChat(chat) } }, - .cancel() - ] - ) + $0.content } } } @@ -430,28 +432,6 @@ struct ChatListNavLink: View { ) } - 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"), @@ -459,30 +439,6 @@ struct ChatListNavLink: View { ) } - 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) @@ -497,16 +453,28 @@ struct ChatListNavLink: View { private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) { Task { - let ok = await connectContactViaAddress(contact.contactId, incognito) + let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) if ok { await MainActor.run { chatModel.chatId = contact.id } + AlertManager.shared.showAlert(connReqSentAlert(.contact)) } } } } +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() + ) +} + func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert { Alert( title: Text("Delete pending connection?"), @@ -533,15 +501,14 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, ) } -func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool { +func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: (Alert) -> Void) async -> Bool { let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId) if let alert = alert { - AlertManager.shared.showAlert(alert) + showAlert(alert) return false } else if let contact = contact { await MainActor.run { ChatModel.shared.updateContact(contact) - AlertManager.shared.showAlert(connReqSentAlert(.contact)) } return true } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index dfaaf1f192..1cb00e3a4d 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -18,12 +18,12 @@ struct ChatListView: View { @State private var searchText = "" @State private var searchShowingSimplexLink = false @State private var searchChatFilteredBySimplexLink: String? = nil - @State private var newChatMenuOption: NewChatMenuOption? = nil @State private var userPickerVisible = false @State private var showConnectDesktop = false @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false - + @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false + var body: some View { if #available(iOS 16.0, *) { viewBody.scrollDismissesKeyboard(.immediately) @@ -42,9 +42,6 @@ struct ChatListView: View { destination: chatView ) { VStack { - if chatModel.chats.isEmpty { - onboardingButtons() - } chatListView } } @@ -69,7 +66,9 @@ struct ChatListView: View { private var chatListView: some View { VStack { chatList + toolbar } + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .onDisappear() { withAnimation { userPickerVisible = false } } .refreshable { AlertManager.shared.showAlert(Alert( @@ -91,7 +90,10 @@ struct ChatListView: View { .background(theme.colors.background) .navigationBarTitleDisplayMode(.inline) .navigationBarHidden(searchMode) - .toolbar { + } + + @ViewBuilder private var toolbar: some View { + let t = VStack{}.toolbar { ToolbarItem(placement: .navigationBarLeading) { let user = chatModel.currentUser ?? User.sampleData ZStack(alignment: .topTrailing) { @@ -124,12 +126,20 @@ struct ChatListView: View { } ToolbarItem(placement: .navigationBarTrailing) { switch chatModel.chatRunning { - case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption) + case .some(true): NewChatMenuButton() case .some(false): chatStoppedIcon() case .none: EmptyView() } } } + + if #unavailable(iOS 16) { + t + } else if oneHandUI { + t.toolbarBackground(.visible, for: .navigationBar) + } else { + t.toolbarBackground(.visible, for: .bottomBar) + } } @ViewBuilder private var chatList: some View { @@ -145,12 +155,14 @@ struct ChatListView: View { searchShowingSimplexLink: $searchShowingSimplexLink, searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink ) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .listRowSeparator(.hidden) .listRowBackground(Color.clear) .frame(maxWidth: .infinity) } ForEach(cs, id: \.viewId) { chat in ChatListNavLink(chat: chat) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .padding(.trailing, -16) .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id)) .listRowBackground(Color.clear) @@ -169,7 +181,9 @@ struct ChatListView: View { stopAudioPlayer() } if cs.isEmpty && !chatModel.chats.isEmpty { - Text("No filtered chats").foregroundColor(theme.colors.secondary) + Text("No filtered chats") + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .foregroundColor(.secondary) } } } @@ -180,42 +194,6 @@ struct ChatListView: View { .foregroundColor(theme.colors.primary) } - private func onboardingButtons() -> some View { - VStack(alignment: .trailing, spacing: 0) { - Path { p in - p.move(to: CGPoint(x: 8, y: 0)) - p.addLine(to: CGPoint(x: 16, y: 10)) - p.addLine(to: CGPoint(x: 0, y: 10)) - p.addLine(to: CGPoint(x: 8, y: 0)) - } - .fill(theme.colors.primary) - .frame(width: 20, height: 10) - .padding(.trailing, 12) - - connectButton("Tap to start a new chat") { - newChatMenuOption = .newContact - } - - Spacer() - Text("You have no chats") - .foregroundColor(theme.colors.secondary) - .frame(maxWidth: .infinity) - } - .padding(.trailing, 6) - .frame(maxHeight: .infinity) - } - - private func connectButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(label) - .padding(.vertical, 10) - .padding(.horizontal, 20) - } - .background(theme.colors.primary) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } - @ViewBuilder private func chatView() -> some View { if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) { ChatView(chat: chat) @@ -233,16 +211,20 @@ struct ChatListView: View { } else { let s = searchString() return s == "" && !showUnreadAndFavorites - ? chatModel.chats + ? chatModel.chats.filter { chat in + !chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card + } : chatModel.chats.filter { chat in let cInfo = chat.chatInfo switch cInfo { case let .direct(contact): - return s == "" - ? filtered(chat) - : (viewNameContains(cInfo, s) || - contact.profile.displayName.localizedLowercase.contains(s) || - contact.fullName.localizedLowercase.contains(s)) + return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && ( + s == "" + ? filtered(chat) + : (viewNameContains(cInfo, s) || + contact.profile.displayName.localizedLowercase.contains(s) || + contact.fullName.localizedLowercase.contains(s)) + ) case let .group(gInfo): return s == "" ? (filtered(chat) || gInfo.membership.memberStatus == .memInvited) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 6db89cd8d2..a6000e90f1 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -275,7 +275,7 @@ struct ChatPreviewView: View { } else { switch (chat.chatInfo) { case let .direct(contact): - if contact.activeConn == nil && contact.profile.contactLink != nil { + if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { chatPreviewInfoText("Tap to Connect") .foregroundColor(theme.colors.primary) } else if !contact.sndReady && contact.activeConn != nil { diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift new file mode 100644 index 0000000000..4079e1474a --- /dev/null +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -0,0 +1,272 @@ +// +// ContactListNavLink.swift +// SimpleX (iOS) +// +// Created by Diogo Cunha on 01/08/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ContactListNavLink: View { + @EnvironmentObject var theme: AppTheme + @ObservedObject var chat: Chat + var showDeletedChatIcon: Bool + @State private var alert: SomeAlert? = nil + @State private var actionSheet: SomeActionSheet? = nil + @State private var sheet: SomeSheet? = nil + @State private var showConnectContactViaAddressDialog = false + @State private var showContactRequestDialog = false + + var body: some View { + let contactType = chatContactType(chat: chat) + + Group { + switch (chat.chatInfo) { + case let .direct(contact): + switch contactType { + case .recent: + recentContactNavLink(contact) + case .chatDeleted: + deletedChatNavLink(contact) + case .card: + contactCardNavLink(contact) + default: + EmptyView() + } + case let .contactRequest(contactRequest): + contactRequestNavLink(contactRequest) + default: + EmptyView() + } + } + .alert(item: $alert) { $0.alert } + .actionSheet(item: $actionSheet) { $0.actionSheet } + .sheet(item: $sheet) { + if #available(iOS 16.0, *) { + $0.content + .presentationDetents([.fraction(0.4)]) + } else { + $0.content + } + } + } + + func recentContactNavLink(_ contact: Contact) -> some View { + Button { + dismissAllSheets(animated: true) { + ChatModel.shared.chatId = contact.id + } + } label: { + contactPreview(contact, titleColor: theme.colors.onBackground) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + deleteContactDialog( + chat, + contact, + dismissToChatList: false, + showAlert: { alert = $0 }, + showActionSheet: { actionSheet = $0 }, + showSheetContent: { sheet = $0 } + ) + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + } + + func deletedChatNavLink(_ contact: Contact) -> some View { + Button { + Task { + await MainActor.run { + var updatedContact = contact + updatedContact.chatDeleted = false + ChatModel.shared.updateContact(updatedContact) + dismissAllSheets(animated: true) { + ChatModel.shared.chatId = contact.id + } + } + } + } label: { + contactPreview(contact, titleColor: theme.colors.onBackground) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + deleteContactDialog( + chat, + contact, + dismissToChatList: false, + showAlert: { alert = $0 }, + showActionSheet: { actionSheet = $0 }, + showSheetContent: { sheet = $0 } + ) + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + } + + func contactPreview(_ contact: Contact, titleColor: Color) -> some View { + HStack{ + ProfileImage(imageStr: contact.image, size: 30) + + previewTitle(contact, titleColor: titleColor) + + Spacer() + + HStack { + if showDeletedChatIcon && contact.chatDeleted { + Image(systemName: "archivebox") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .foregroundColor(.secondary.opacity(0.65)) + } else if chat.chatInfo.chatSettings?.favorite ?? false { + Image(systemName: "star.fill") + .resizable() + .scaledToFill() + .frame(width: 18, height: 18) + .foregroundColor(.secondary.opacity(0.65)) + } + if contact.contactConnIncognito { + Image(systemName: "theatermasks") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundColor(.secondary) + } + } + } + } + + @ViewBuilder private func previewTitle(_ contact: Contact, titleColor: Color) -> some View { + let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor) + ( + contact.verified == true + ? verifiedIcon + t + : t + ) + .lineLimit(1) + } + + private var verifiedIcon: Text { + (Text(Image(systemName: "checkmark.shield")) + Text(" ")) + .foregroundColor(.secondary) + .baselineOffset(1) + .kerning(-2) + } + + func contactCardNavLink(_ contact: Contact) -> some View { + Button { + showConnectContactViaAddressDialog = true + } label: { + contactCardPreview(contact) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + deleteContactDialog( + chat, + contact, + dismissToChatList: false, + showAlert: { alert = $0 }, + showActionSheet: { actionSheet = $0 }, + showSheetContent: { sheet = $0 } + ) + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + .confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) { + Button("Use current profile") { connectContactViaAddress_(contact, false) } + Button("Use new incognito profile") { connectContactViaAddress_(contact, true) } + } + } + + private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) { + Task { + let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") }) + if ok { + await MainActor.run { + ChatModel.shared.chatId = contact.id + } + DispatchQueue.main.async { + dismissAllSheets(animated: true) { + AlertManager.shared.showAlert(connReqSentAlert(.contact)) + } + } + } + } + } + + func contactCardPreview(_ contact: Contact) -> some View { + HStack{ + ProfileImage(imageStr: contact.image, size: 30) + + Text(chat.chatInfo.chatViewName) + .foregroundColor(.accentColor) + .lineLimit(1) + + Spacer() + + Image(systemName: "envelope") + .resizable() + .scaledToFill() + .frame(width: 14, height: 14) + .foregroundColor(.accentColor) + } + } + + func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { + Button { + showContactRequestDialog = true + } label: { + contactRequestPreview(contactRequest) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } + } label: { Label("Accept", systemImage: "checkmark") } + .tint(theme.colors.primary) + Button { + Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } + } label: { + Label("Accept incognito", systemImage: "theatermasks") + } + .tint(.indigo) + Button { + alert = SomeAlert(alert: rejectContactRequestAlert(contactRequest), id: "rejectContactRequestAlert") + } label: { + Label("Reject", systemImage: "multiply") + } + .tint(.red) + } + .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) } } + } + } + + func contactRequestPreview(_ contactRequest: UserContactRequest) -> some View { + HStack{ + ProfileImage(imageStr: contactRequest.image, size: 30) + + Text(chat.chatInfo.chatViewName) + .foregroundColor(.accentColor) + .lineLimit(1) + + Spacer() + + Image(systemName: "checkmark") + .resizable() + .scaledToFill() + .frame(width: 14, height: 14) + .foregroundColor(.accentColor) + } + } +} diff --git a/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift b/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift index 5e6d44f686..6001dff790 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift @@ -30,7 +30,7 @@ struct AddContactLearnMore: View { } .listRowBackground(Color.clear) } - .modifier(ThemedBackground()) + .modifier(ThemedBackground(grouped: true)) } } diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 42428dfb55..7e7944f984 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -35,24 +35,28 @@ struct AddGroupView: View { creatingGroup: true, showFooterCounter: false ) { _ in - dismiss() DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - m.chatId = groupInfo.id + dismissAllSheets(animated: true) { + m.chatId = groupInfo.id + } } } + .navigationBarTitleDisplayMode(.inline) } else { GroupLinkView( groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole, - showTitle: true, + showTitle: false, creatingGroup: true ) { - dismiss() DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - m.chatId = groupInfo.id + dismissAllSheets(animated: true) { + m.chatId = groupInfo.id + } } } + .navigationBarTitle("Group link") } } else { createGroupView().keyboardPadding() @@ -62,13 +66,6 @@ struct AddGroupView: View { func createGroupView() -> some View { List { Group { - Text("Create secret group") - .font(.largeTitle) - .bold() - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 24) - .onTapGesture(perform: hideKeyboard) - ZStack(alignment: .center) { ZStack(alignment: .topTrailing) { ProfileImage(imageStr: profile.image, size: 128) @@ -204,13 +201,14 @@ struct AddGroupView: View { chat = c } } catch { - dismiss() - AlertManager.shared.showAlert( - Alert( - title: Text("Error creating group"), - message: Text(responseError(error)) + dismissAllSheets(animated: true) { + AlertManager.shared.showAlert( + Alert( + title: Text("Error creating group"), + message: Text(responseError(error)) + ) ) - ) + } } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 3be1095bfd..b06047befe 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -7,53 +7,454 @@ // import SwiftUI +import SimpleXChat -enum NewChatMenuOption: Identifiable { - case newContact - case scanPaste - case newGroup - - var id: Self { self } +enum ContactType: Int { + case card, request, recent, chatDeleted, unlisted } struct NewChatMenuButton: View { - @Binding var newChatMenuOption: NewChatMenuOption? + @State private var showNewChatSheet = false + @State private var alert: SomeAlert? = nil + @State private var globalAlert: SomeAlert? = nil var body: some View { - Menu { Button { - newChatMenuOption = .newContact - } label: { - Text("Add contact") - } - Button { - newChatMenuOption = .scanPaste - } label: { - Text("Scan / Paste link") - } - Button { - newChatMenuOption = .newGroup - } label: { - Text("Create group") - } + showNewChatSheet = true } label: { Image(systemName: "square.and.pencil") .resizable() .scaledToFit() .frame(width: 24, height: 24) } - .sheet(item: $newChatMenuOption) { opt in - switch opt { - case .newContact: NewChatView(selection: .invite) - case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true) - case .newGroup: AddGroupView() + .appSheet(isPresented: $showNewChatSheet) { + NewChatSheet(alert: $alert) + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + .alert(item: $alert) { a in + return a.alert + } + } + // This is a workaround to show "Keep unused invitation" alert in both following cases: + // - on going back from NewChatView to NewChatSheet, + // - on dismissing NewChatMenuButton sheet while on NewChatView (skipping NewChatSheet) + .onChange(of: alert?.id) { a in + if !showNewChatSheet && alert != nil { + globalAlert = alert + alert = nil } } + .alert(item: $globalAlert) { a in + return a.alert + } } } -#Preview { - NewChatMenuButton( - newChatMenuOption: Binding.constant(nil) - ) +private var indent: CGFloat = 36 + +struct NewChatSheet: View { + @EnvironmentObject var theme: AppTheme + @State private var baseContactTypes: [ContactType] = [.card, .request, .recent] + @EnvironmentObject var chatModel: ChatModel + @State private var searchMode = false + @FocusState var searchFocussed: Bool + @State private var searchText = "" + @State private var searchShowingSimplexLink = false + @State private var searchChatFilteredBySimplexLink: String? = nil + @Binding var alert: SomeAlert? + + var body: some View { + NavigationView { + viewBody() + .navigationTitle("New Chat") + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(searchMode) + .modifier(ThemedBackground(grouped: true)) + } + } + + @ViewBuilder private func viewBody() -> some View { + List { + HStack { + ContactsListSearchBar( + searchMode: $searchMode, + searchFocussed: $searchFocussed, + searchText: $searchText, + searchShowingSimplexLink: $searchShowingSimplexLink, + searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink + ) + .frame(maxWidth: .infinity) + } + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + + if (searchText.isEmpty) { + Section { + NavigationLink { + NewChatView(selection: .invite, parentAlert: $alert) + .navigationTitle("New chat") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Add contact", systemImage: "link.badge.plus") + } + NavigationLink { + NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert) + .navigationTitle("New chat") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Scan / Paste link", systemImage: "qrcode") + } + NavigationLink { + AddGroupView() + .navigationTitle("Create secret group") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Create group", systemImage: "person.2") + } + } + + if (!filterContactTypes(chats: chatModel.chats, contactTypes: [.chatDeleted]).isEmpty) { + Section { + NavigationLink { + DeletedChats() + } label: { + newChatActionButton("archivebox", color: theme.colors.secondary) { Text("Deleted chats") } + } + } + } + } + + ContactsList( + baseContactTypes: $baseContactTypes, + searchMode: $searchMode, + searchText: $searchText, + header: "Your Contacts", + searchFocussed: $searchFocussed, + searchShowingSimplexLink: $searchShowingSimplexLink, + searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink, + showDeletedChatIcon: true + ) + } + } + + func newChatActionButton(_ icon: String, color: Color/* = .secondary*/, content: @escaping () -> Content) -> some View { + ZStack(alignment: .leading) { + Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center) + .symbolRenderingMode(.monochrome) + .foregroundColor(color) + content().foregroundColor(theme.colors.onBackground).padding(.leading, indent) + } + } +} + +func chatContactType(chat: Chat) -> ContactType { + switch chat.chatInfo { + case .contactRequest: + return .request + case let .direct(contact): + if contact.activeConn == nil && contact.profile.contactLink != nil { + return .card + } else if contact.chatDeleted { + return .chatDeleted + } else if contact.contactStatus == .active { + return .recent + } else { + return .unlisted + } + default: + return .unlisted + } +} + +private func filterContactTypes(chats: [Chat], contactTypes: [ContactType]) -> [Chat] { + return chats.filter { chat in + contactTypes.contains(chatContactType(chat: chat)) + } +} + +struct ContactsList: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @Binding var baseContactTypes: [ContactType] + @Binding var searchMode: Bool + @Binding var searchText: String + var header: String? = nil + @FocusState.Binding var searchFocussed: Bool + @Binding var searchShowingSimplexLink: Bool + @Binding var searchChatFilteredBySimplexLink: String? + var showDeletedChatIcon: Bool + @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false + + var body: some View { + let contactTypes = contactTypesSearchTargets(baseContactTypes: baseContactTypes, searchEmpty: searchText.isEmpty) + let contactChats = filterContactTypes(chats: chatModel.chats, contactTypes: contactTypes) + let filteredContactChats = filteredContactChats( + showUnreadAndFavorites: showUnreadAndFavorites, + searchShowingSimplexLink: searchShowingSimplexLink, + searchChatFilteredBySimplexLink: searchChatFilteredBySimplexLink, + searchText: searchText, + contactChats: contactChats + ) + + if !filteredContactChats.isEmpty { + Section(header: Group { + if let header = header { + Text(header) + .textCase(.uppercase) + .foregroundColor(theme.colors.secondary) + } + } + ) { + ForEach(filteredContactChats, id: \.viewId) { chat in + ContactListNavLink(chat: chat, showDeletedChatIcon: showDeletedChatIcon) + .disabled(chatModel.chatRunning != true) + } + } + } + + if filteredContactChats.isEmpty && !contactChats.isEmpty { + noResultSection(text: "No filtered contacts") + } else if contactChats.isEmpty { + noResultSection(text: "No contacts") + } + } + + @ViewBuilder private func noResultSection(text: String) -> some View { + Section { + Text(text) + .foregroundColor(theme.colors.secondary) + .frame(maxWidth: .infinity, alignment: .center) + + } + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 7, leading: 0, bottom: 7, trailing: 0)) + } + + private func contactTypesSearchTargets(baseContactTypes: [ContactType], searchEmpty: Bool) -> [ContactType] { + if baseContactTypes.contains(.chatDeleted) || searchEmpty { + return baseContactTypes + } else { + return baseContactTypes + [.chatDeleted] + } + } + + private func chatsByTypeComparator(chat1: Chat, chat2: Chat) -> Bool { + let chat1Type = chatContactType(chat: chat1) + let chat2Type = chatContactType(chat: chat2) + + if chat1Type.rawValue < chat2Type.rawValue { + return true + } else if chat1Type.rawValue > chat2Type.rawValue { + return false + } else { + return chat2.chatInfo.chatTs < chat1.chatInfo.chatTs + } + } + + private func filterChat(chat: Chat, searchText: String, showUnreadAndFavorites: Bool) -> Bool { + var meetsPredicate = true + let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let cInfo = chat.chatInfo + + if !searchText.isEmpty { + if (!cInfo.chatViewName.lowercased().contains(searchText.lowercased())) { + if case let .direct(contact) = cInfo { + meetsPredicate = contact.profile.displayName.lowercased().contains(s) || contact.fullName.lowercased().contains(s) + } else { + meetsPredicate = false + } + } + } + + if showUnreadAndFavorites { + meetsPredicate = meetsPredicate && (cInfo.chatSettings?.favorite ?? false) + } + + return meetsPredicate + } + + func filteredContactChats( + showUnreadAndFavorites: Bool, + searchShowingSimplexLink: Bool, + searchChatFilteredBySimplexLink: String?, + searchText: String, + contactChats: [Chat] + ) -> [Chat] { + let linkChatId = searchChatFilteredBySimplexLink + let s = searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + let filteredChats: [Chat] + + if let linkChatId = linkChatId { + filteredChats = contactChats.filter { $0.id == linkChatId } + } else { + filteredChats = contactChats.filter { chat in + filterChat(chat: chat, searchText: s, showUnreadAndFavorites: showUnreadAndFavorites) + } + } + + return filteredChats.sorted(by: chatsByTypeComparator) + } +} + +struct ContactsListSearchBar: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme + @Binding var searchMode: Bool + @FocusState.Binding var searchFocussed: Bool + @Binding var searchText: String + @Binding var searchShowingSimplexLink: Bool + @Binding var searchChatFilteredBySimplexLink: String? + @State private var ignoreSearchTextChange = false + @State private var alert: PlanAndConnectAlert? + @State private var sheet: PlanAndConnectActionSheet? + @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false + + var body: some View { + HStack(spacing: 12) { + HStack(spacing: 4) { + Spacer() + .frame(width: 8) + Image(systemName: "magnifyingglass") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + TextField("Search or paste SimpleX link", text: $searchText) + .foregroundColor(searchShowingSimplexLink ? theme.colors.secondary : theme.colors.onBackground) + .disabled(searchShowingSimplexLink) + .focused($searchFocussed) + .frame(maxWidth: .infinity) + if !searchText.isEmpty { + Image(systemName: "xmark.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .onTapGesture { + searchText = "" + } + } + } + .padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7)) + .foregroundColor(theme.colors.secondary) + .background(theme.colors.isLight ? theme.colors.background : theme.colors.secondaryVariant) + .cornerRadius(10.0) + + if searchFocussed { + Text("Cancel") + .foregroundColor(theme.colors.primary) + .onTapGesture { + searchText = "" + searchFocussed = false + } + } else if m.chats.count > 0 { + toggleFilterButton() + } + } + .onChange(of: searchFocussed) { sf in + withAnimation { searchMode = sf } + } + .onChange(of: searchText) { t in + if ignoreSearchTextChange { + ignoreSearchTextChange = false + } else { + if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue + searchFocussed = false + if case let .simplexLink(linkType, _, smpHosts) = link.format { + ignoreSearchTextChange = true + searchText = simplexLinkText(linkType, smpHosts) + } + searchShowingSimplexLink = true + searchChatFilteredBySimplexLink = nil + connect(link.text) + } else { + if t != "" { // if some other text is pasted, enter search mode + searchFocussed = true + } + searchShowingSimplexLink = false + searchChatFilteredBySimplexLink = nil + } + } + } + .alert(item: $alert) { a in + planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" }) + } + .actionSheet(item: $sheet) { s in + planAndConnectActionSheet(s, dismiss: true, cleanup: { searchText = "" }) + } + } + + private func toggleFilterButton() -> some View { + ZStack { + Color.clear + .frame(width: 22, height: 22) + Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease") + .resizable() + .scaledToFit() + .foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary) + .frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16) + .onTapGesture { + showUnreadAndFavorites = !showUnreadAndFavorites + } + } + } + + private func connect(_ link: String) { + planAndConnect( + link, + showAlert: { alert = $0 }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: nil, + filterKnownContact: { searchChatFilteredBySimplexLink = $0.id } + ) + } +} + + +struct DeletedChats: View { + @State private var baseContactTypes: [ContactType] = [.chatDeleted] + @State private var searchMode = false + @FocusState var searchFocussed: Bool + @State private var searchText = "" + @State private var searchShowingSimplexLink = false + @State private var searchChatFilteredBySimplexLink: String? = nil + + var body: some View { + List { + ContactsListSearchBar( + searchMode: $searchMode, + searchFocussed: $searchFocussed, + searchText: $searchText, + searchShowingSimplexLink: $searchShowingSimplexLink, + searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink + ) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .frame(maxWidth: .infinity) + + ContactsList( + baseContactTypes: $baseContactTypes, + searchMode: $searchMode, + searchText: $searchText, + searchFocussed: $searchFocussed, + searchShowingSimplexLink: $searchShowingSimplexLink, + searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink, + showDeletedChatIcon: false + ) + } + .navigationTitle("Deleted chats") + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(searchMode) + .modifier(ThemedBackground(grouped: true)) + + } +} + +#Preview { + NewChatMenuButton() } diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index df50bf9df2..cfe3dbb654 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -17,10 +17,19 @@ struct SomeAlert: Identifiable { var id: String } +struct SomeActionSheet: Identifiable { + var actionSheet: ActionSheet + var id: String +} + +struct SomeSheet: Identifiable { + @ViewBuilder var content: Content + var id: String +} + private enum NewChatViewAlert: Identifiable { case planAndConnectAlert(alert: PlanAndConnectAlert) case newChatSomeAlert(alert: SomeAlert) - var id: String { switch self { case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)" @@ -47,22 +56,10 @@ struct NewChatView: View { @State private var creatingConnReq = false @State private var pastedLink: String = "" @State private var alert: NewChatViewAlert? + @Binding var parentAlert: SomeAlert? var body: some View { VStack(alignment: .leading) { - HStack { - Text("New chat") - .font(.largeTitle) - .bold() - .fixedSize(horizontal: false, vertical: true) - Spacer() - InfoSheetButton { - AddContactLearnMore(showTitle: true) - } - } - .padding() - .padding(.top) - Picker("New chat", selection: $selection) { Label("Add contact", systemImage: "link") .tag(NewChatOption.invite) @@ -88,6 +85,7 @@ struct NewChatView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .modifier(ThemedBackground(grouped: true)) .background( // Rectangle is needed for swipe gesture to work on mostly empty views (creatingLinkProgressView and retryButton) Rectangle() @@ -110,6 +108,13 @@ struct NewChatView: View { } ) } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + InfoSheetButton { + AddContactLearnMore(showTitle: true) + } + } + } .modifier(ThemedBackground(grouped: true)) .onChange(of: invitationUsed) { used in if used && !(m.showingInvitation?.connChatUsed ?? true) { @@ -119,19 +124,22 @@ struct NewChatView: View { .onDisappear { if !(m.showingInvitation?.connChatUsed ?? true), let conn = contactConnection { - AlertManager.shared.showAlert(Alert( - title: Text("Keep unused invitation?"), - message: Text("You can view invitation link again in connection details."), - primaryButton: .default(Text("Keep")) {}, - secondaryButton: .destructive(Text("Delete")) { - Task { - await deleteChat(Chat( - chatInfo: .contactConnection(contactConnection: conn), - chatItems: [] - )) + parentAlert = SomeAlert( + alert: Alert( + title: Text("Keep unused invitation?"), + message: Text("You can view invitation link again in connection details."), + primaryButton: .default(Text("Keep")) {}, + secondaryButton: .destructive(Text("Delete")) { + Task { + await deleteChat(Chat( + chatInfo: .contactConnection(contactConnection: conn), + chatItems: [] + )) + } } - } - )) + ), + id: "keepUnusedInvitation" + ) } m.showingInvitation = nil } @@ -837,7 +845,10 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn dismissAllSheets(animated: true) } } - _ = await connectContactViaAddress(contact.contactId, incognito) + let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) + if ok { + AlertManager.shared.showAlert(connReqSentAlert(.contact)) + } cleanup?() } } @@ -961,8 +972,13 @@ func connReqSentAlert(_ type: ConnReqType) -> Alert { ) } -#Preview { - NewChatView( - selection: .invite - ) +struct NewChatView_Previews: PreviewProvider { + static var previews: some View { + @State var parentAlert: SomeAlert? + + NewChatView( + selection: .invite, + parentAlert: $parentAlert + ) + } } diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index ab5da53a9c..66cb41a57d 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -50,6 +50,7 @@ extension AppSettings { if let val = uiDarkColorScheme { def.setValue(val, forKey: DEFAULT_SYSTEM_DARK_THEME) } if let val = uiCurrentThemeIds { def.setValue(val, forKey: DEFAULT_CURRENT_THEME_IDS) } if let val = uiThemes { def.setValue(val.skipDuplicates(), forKey: DEFAULT_THEME_OVERRIDES) } + if let val = oneHandUI { def.setValue(val, forKey: DEFAULT_ONE_HAND_UI) } } public static var current: AppSettings { @@ -81,6 +82,7 @@ extension AppSettings { c.uiDarkColorScheme = systemDarkThemeDefault.get() c.uiCurrentThemeIds = currentThemeIdsDefault.get() c.uiThemes = themeOverridesDefault.get() + c.oneHandUI = def.bool(forKey: DEFAULT_ONE_HAND_UI) return c } } diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 5d667655e3..7f18d43a5e 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -33,6 +33,7 @@ struct AppearanceSettings: View { }() @State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner + @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false @State var themeUserDestination: (Int64, ThemeModeOverrides?)? = { if let currentUser = ChatModel.shared.currentUser, let uiThemes = currentUser.uiThemes, uiThemes.preferredMode(!CurrentColors.colors.isLight) != nil { diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index e55ff5cfb6..41a580496f 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -13,6 +13,7 @@ struct DeveloperView: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false + @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false @Environment(\.colorScheme) var colorScheme var body: some View { @@ -33,9 +34,6 @@ struct DeveloperView: View { } label: { settingsRow("terminal", color: theme.colors.secondary) { Text("Chat console") } } - settingsRow("internaldrive", color: theme.colors.secondary) { - Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades) - } settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) { Toggle("Show developer options", isOn: $developerTools) } @@ -45,6 +43,19 @@ struct DeveloperView: View { ((developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.")) .foregroundColor(theme.colors.secondary) } + + if developerTools { + Section { + settingsRow("internaldrive", color: theme.colors.secondary) { + Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades) + } + settingsRow("hand.wave", color: theme.colors.secondary) { + Toggle("One-hand UI", isOn: $oneHandUI) + } + } header: { + Text("Developer options") + } + } } } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index fcbef61888..59e4f8af35 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -47,6 +47,7 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius" +let DEFAULT_ONE_HAND_UI = "oneHandUI" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice" @@ -61,6 +62,8 @@ let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess" let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto" +let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice" +let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice" let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage" @@ -94,6 +97,7 @@ let appDefaults: [String: Any] = [ DEFAULT_DEVELOPER_TOOLS: false, DEFAULT_ENCRYPTION_STARTED: false, DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner, + DEFAULT_ONE_HAND_UI: false, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true, @@ -104,6 +108,8 @@ let appDefaults: [String: Any] = [ DEFAULT_CONFIRM_REMOTE_SESSIONS: false, DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true, DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true, + DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE: true, + DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true, DEFAULT_SHOW_SENT_VIA_RPOXY: false, DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false, ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue, @@ -158,6 +164,9 @@ let onboardingStageDefault = EnumDefault(defaults: UserDefaults let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME) +let showDeleteConversationNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE) +let showDeleteContactNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONTACT_NOTICE) + let currentThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME, withDefault: DefaultTheme.SYSTEM_THEME_NAME) let systemDarkThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SYSTEM_DARK_THEME, withDefault: DefaultTheme.DARK.themeName) let currentThemeIdsDefault = CodableDefault<[String: String]>(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME_IDS, withDefault: [:] ) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 20c242c51f..6c9e114e53 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -194,6 +194,7 @@ 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; + B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; }; CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; }; CE3097FB2C4C0C9F00180898 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */; }; @@ -531,6 +532,7 @@ 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; + B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = ""; }; CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = ""; }; CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = ""; }; @@ -685,6 +687,7 @@ 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( + B76E6C2F2C5C41C300EC11AA /* Contacts */, 5CB0BA8C282711BC00B3292C /* Onboarding */, 3C714775281C080100CB4D4B /* Call */, 5C971E1F27AEBF7000C8A3CE /* Helpers */, @@ -1076,6 +1079,14 @@ path = Theme; sourceTree = ""; }; + B76E6C2F2C5C41C300EC11AA /* Contacts */ = { + isa = PBXGroup; + children = ( + B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */, + ); + path = Contacts; + sourceTree = ""; + }; CEE723A82C3BD3D70009AE93 /* SimpleX SE */ = { isa = PBXGroup; children = ( @@ -1382,6 +1393,7 @@ 64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, + B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */, 5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */, D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */, CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b839804b79..570946155a 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -101,7 +101,7 @@ public enum ChatCommand { case apiConnectPlan(userId: Int64, connReq: String) case apiConnect(userId: Int64, incognito: Bool, connReq: String) case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64) - case apiDeleteChat(type: ChatType, id: Int64, notify: Bool?) + case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode) case apiClearChat(type: ChatType, id: Int64) case apiListContacts(userId: Int64) case apiUpdateProfile(userId: Int64, profile: Profile) @@ -266,11 +266,7 @@ public enum ChatCommand { case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)" - case let .apiDeleteChat(type, id, notify): if let notify = notify { - return "/_delete \(ref(type, id)) notify=\(onOff(notify))" - } else { - return "/_delete \(ref(type, id))" - } + case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id)) \(chatDeleteMode.cmdString)" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case let .apiListContacts(userId): return "/_contacts \(userId)" case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" @@ -505,10 +501,6 @@ public enum ChatCommand { return nil } - private func onOff(_ b: Bool) -> String { - b ? "on" : "off" - } - private func onOffParam(_ param: String, _ b: Bool?) -> String { if let b = b { return " \(param)=\(onOff(b))" @@ -521,6 +513,10 @@ public enum ChatCommand { } } +private func onOff(_ b: Bool) -> String { + b ? "on" : "off" +} + public struct APIResponse: Decodable { var resp: ChatResponse } @@ -1048,6 +1044,27 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? { } } +public enum ChatDeleteMode: Codable { + case full(notify: Bool) + case entity(notify: Bool) + case messages + + var cmdString: String { + switch self { + case let .full(notify): "full notify=\(onOff(notify))" + case let .entity(notify): "entity notify=\(onOff(notify))" + case .messages: "messages" + } + } + + public var isEntity: Bool { + switch self { + case .entity: return true + default: return false + } + } +} + public enum ConnectionPlan: Decodable, Hashable { case invitationLink(invitationLinkPlan: InvitationLinkPlan) case contactAddress(contactAddressPlan: ContactAddressPlan) @@ -2141,7 +2158,8 @@ public struct AppSettings: Codable, Equatable, Hashable { public var uiDarkColorScheme: String? = nil public var uiCurrentThemeIds: [String: String]? = nil public var uiThemes: [ThemeOverrides]? = nil - + public var oneHandUI: Bool? = nil + public func prepareForExport() -> AppSettings { var empty = AppSettings() let def = AppSettings.defaults @@ -2171,6 +2189,7 @@ public struct AppSettings: Codable, Equatable, Hashable { if uiDarkColorScheme != def.uiDarkColorScheme { empty.uiDarkColorScheme = uiDarkColorScheme } if uiCurrentThemeIds != def.uiCurrentThemeIds { empty.uiCurrentThemeIds = uiCurrentThemeIds } if uiThemes != def.uiThemes { empty.uiThemes = uiThemes } + if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI } return empty } @@ -2201,7 +2220,8 @@ public struct AppSettings: Codable, Equatable, Hashable { uiColorScheme: DefaultTheme.SYSTEM_THEME_NAME, uiDarkColorScheme: DefaultTheme.SIMPLEX.themeName, uiCurrentThemeIds: nil as [String: String]?, - uiThemes: nil as [ThemeOverrides]? + uiThemes: nil as [ThemeOverrides]?, + oneHandUI: false ) } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f9eb0ed39e..233f4c19ee 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1289,6 +1289,15 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } } + + public var chatDeleted: Bool { + get { + switch self { + case let .direct(contact): return contact.chatDeleted + default: return false + } + } + } public var sendMsgEnabled: Bool { get { @@ -1401,6 +1410,27 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public enum ShowEnableCallsAlert: Hashable { + case userEnable + case askContact + case other + } + + public var showEnableCallsAlert: ShowEnableCallsAlert { + switch self { + case let .direct(contact): + if contact.mergedPreferences.calls.userPreference.preference.allow == .no { + return .userEnable + } else if contact.mergedPreferences.calls.contactPreference.allow == .no { + return .askContact + } else { + return .other + } + default: + return .other + } + } + public var ntfsEnabled: Bool { self.chatSettings?.enableNtfs == .all } @@ -1508,7 +1538,8 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { var contactGroupMemberId: Int64? var contactGrpInvSent: Bool public var uiThemes: ThemeModeOverrides? - + public var chatDeleted: Bool + public var id: ChatId { get { "@\(contactId)" } } public var apiId: Int64 { get { contactId } } public var ready: Bool { get { activeConn?.connStatus == .ready } } @@ -1575,13 +1606,15 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { mergedPreferences: ContactUserPreferences.sampleData, createdAt: .now, updatedAt: .now, - contactGrpInvSent: false + contactGrpInvSent: false, + chatDeleted: false ) } public enum ContactStatus: String, Decodable, Hashable { case active = "active" case deleted = "deleted" + case deletedByUser = "deletedByUser" } public struct ContactRef: Decodable, Equatable, Hashable {