From 8f8601eaa43c3efca0f5b3247d2c1c1076abeff1 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 13 May 2024 17:04:46 +0400 Subject: [PATCH] ios: UI redesign, contacts (#4116) * ios: chat delete mode api * redesign wip * wip * filter button right of search * rework navigation (mostly works?) * remove modifier * search in bottom bar * make filter button easier to press * increase button size * customizable search position * reverse chat list wip * change material * list going behind toolbars * increase spacing * rework wip * rework, sheets * more scale effect * remove search buttons, rework filter button * remove onboarding buttons * scan/paste menu * wip * contacts wip * sizes * remove unnecessary modifier * contacts navigation wip * paddings * rework chat info view approach * comment * comment * verified marker * comment * fix list not updating * delete contact/conversation * delete via chat list (has bugs) * comment * swipe on contact list * fixes * buttons wip * message button to open chat * buttons disabled * call buttons work from sheet * call button from contacts * fix buttons * show keyboard attempts * Revert "show keyboard attempts" This reverts commit daa50d1aa934b719b0d9dcabbec8d42631f57110. * comment * mark contact chat as not deleted when opening from contacts * move to old view * dont reverse contacts in one-hand mode * change icons * simplify call buttons (revert to make calls from chat view) * top bar, reduce padding * increase filter button size * support for contact cards * fix some delete conversation bugs * fix chat not being removed from list on deleting conversation * add to app settings * member view buttons * icons * remove unused code * padding * avatar * resize avatar * button * add open button for deleted contact * add deletedByUser status * rework delete actions * filter button in contacts list --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/ContentView.swift | 2 +- apps/ios/Shared/Model/SimpleXAPI.swift | 62 +++- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 270 +++++++++++++++--- apps/ios/Shared/Views/Chat/ChatView.swift | 34 +-- .../Chat/Contacts/ContactListNavLink.swift | 210 ++++++++++++++ .../Views/Chat/Contacts/ContactsView.swift | 199 +++++++++++++ .../Chat/Group/GroupMemberInfoView.swift | 137 +++++---- apps/ios/Shared/Views/ChatList/ChatHelp.swift | 3 +- .../Views/ChatList/ChatListNavLink.swift | 98 +++++-- .../Shared/Views/ChatList/ChatListView.swift | 218 +++----------- .../Views/ChatList/ChatPreviewView.swift | 2 +- apps/ios/Shared/Views/Home/HomeView.swift | 219 ++++++++++++++ .../Views/NewChat/NewChatMenuButton.swift | 7 + .../Views/UserSettings/AppSettings.swift | 2 + .../UserSettings/AppearanceSettings.swift | 7 + .../Views/UserSettings/SettingsView.swift | 11 +- .../Views/UserSettings/UserProfilesView.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 28 ++ apps/ios/SimpleXChat/APITypes.swift | 19 +- apps/ios/SimpleXChat/ChatTypes.swift | 14 +- 20 files changed, 1210 insertions(+), 334 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/Contacts/ContactListNavLink.swift create mode 100644 apps/ios/Shared/Views/Chat/Contacts/ContactsView.swift create mode 100644 apps/ios/Shared/Views/Home/HomeView.swift diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index acea38e69e..d1b6c82853 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -230,7 +230,7 @@ struct ContentView: View { private func mainView() -> some View { ZStack(alignment: .top) { - ChatListView(showSettings: $showSettings).privacySensitive(protectScreen) + HomeView(showSettings: $showSettings).privacySensitive(protectScreen) .onAppear { requestNtfAuthorization() // Local Authentication notice is to be shown on next start after onboarding is complete diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index b4c9a48d5d..7b85d4e7e8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -732,21 +732,37 @@ 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) + try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode) DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) } } catch let error { logger.error("deleteChat apiDeleteChat error: \(responseError(error))") @@ -757,6 +773,39 @@ func deleteChat(_ chat: Chat, notify: Bool? = nil) async { } } +func deleteChatContact(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async { + do { + let cInfo = chat.chatInfo + let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode) + DispatchQueue.main.async { + switch chatDeleteMode { + case .full: + ChatModel.shared.removeChat(cInfo.id) + case .entity: + // removeChat forces list update (for ContactsView) + ChatModel.shared.removeChat(cInfo.id) + ChatModel.shared.addChat(Chat( + chatInfo: .direct(contact: ct), + chatItems: chat.chatItems + )) + case .messages: + // removeChat forces list update (for ChatListView) + ChatModel.shared.removeChat(cInfo.id) + ChatModel.shared.addChat(Chat( + chatInfo: .direct(contact: ct), + chatItems: [] + )) + } + } + } catch let error { + logger.error("deleteChatContact apiDeleteContact error: \(responseError(error))") + AlertManager.shared.showAlertMsg( + title: "Error deleting chat!", + message: "Error: \(responseError(error))" + ) + } +} + 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 } @@ -1607,6 +1656,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 55e84f20d3..5d1fe7ec4e 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -88,21 +88,36 @@ enum SendReceipts: Identifiable, Hashable { } } +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 ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction + var openedFromChatView: Bool @ObservedObject var chat: Chat @State var contact: Contact - @Binding var connectionStats: ConnectionStats? - @Binding var customUserProfile: Profile? @State var localAlias: String - @Binding var connectionCode: String? + @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: ChatInfoViewActionSheet? = 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 + @AppStorage(DEFAULT_SHOW_DELETE_CONTACT_NOTICE) private var showDeleteContactNotice = true enum ChatInfoViewAlert: Identifiable { case clearChatAlert @@ -110,6 +125,7 @@ struct ChatInfoView: View { case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert + case deleteContactNotice case error(title: LocalizedStringKey, error: LocalizedStringKey = "") var id: String { @@ -119,11 +135,24 @@ struct ChatInfoView: View { case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" + case .deleteContactNotice: return "deleteContactNotice" case let .error(title, _): return "error \(title)" } } } + enum ChatInfoViewActionSheet: Identifiable { + case deleteContactActionSheet + case confirmDeleteContactActionSheet(contactDeleteMode: ContactDeleteMode) + + var id: String { + switch self { + case .deleteContactActionSheet: return "deleteContactActionSheet" + case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet" + } + } + } + var body: some View { NavigationView { List { @@ -140,6 +169,23 @@ struct ChatInfoView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) + HStack { + if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { + connectButton() + } else if !contact.active && !contact.chatDeleted { + openButton() + } else { + messageButton() + } + Spacer() + callButton() + Spacer() + videoButton() + } + .padding(.horizontal) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + if let customUserProfile = customUserProfile { Section("Incognito") { HStack { @@ -159,9 +205,9 @@ struct ChatInfoView: View { connStats.ratchetSyncAllowed { synchronizeConnectionButton() } -// } else if developerTools { -// synchronizeConnectionButtonForce() -// } + // } else if developerTools { + // synchronizeConnectionButtonForce() + // } } .disabled(!contact.ready || !contact.active) @@ -235,6 +281,23 @@ 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) { @@ -243,27 +306,46 @@ struct ChatInfoView: View { case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) }) + case .deleteContactNotice: return deleteContactNotice(contact) case let .error(title, error): return mkAlert(title: title, message: error) } } - .actionSheet(isPresented: $showDeleteContactActionSheet) { - if contact.ready && 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) { sheet in + switch(sheet) { + case .deleteContactActionSheet: + var sheetButtons: [ActionSheet.Button] = [] + sheetButtons.append( + .destructive(Text("Delete contact")) { actionSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .full) } ) - } else { + if !contact.chatDeleted { + sheetButtons.append( + .destructive(Text("Delete contact, keep conversation")) { actionSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .entity) } + ) + } + sheetButtons.append(.cancel()) return ActionSheet( - title: Text("Delete contact?\nThis cannot be undone!"), - buttons: [ - .destructive(Text("Delete")) { deleteContact() }, - .cancel() - ] + title: Text("Delete contact?"), + buttons: sheetButtons ) + case let .confirmDeleteContactActionSheet(contactDeleteMode): + if contact.ready && contact.active { + return ActionSheet( + title: Text("Notify contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete and notify contact")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true)) }, + .destructive(Text("Delete without notification")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false)) }, + .cancel() + ] + ) + } else { + return ActionSheet( + title: Text("Confirm contact deletion.\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false)) }, + .cancel() + ] + ) + } } } } @@ -334,6 +416,83 @@ struct ChatInfoView: View { } } + // when contact is a "contact card" + private func connectButton() -> some View { + InfoViewActionButtonLayout(image: "message.fill", title: "connect") + .onTapGesture { + showConnectContactViaAddressDialog = true + } + .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) + if ok { + await MainActor.run { + if openedFromChatView { + dismiss() + } else { + if contact.chatDeleted { + var updatedContact = contact + updatedContact.chatDeleted = false + chatModel.updateContact(updatedContact) + } + chatModel.chatId = chat.id + } + } + } + } + } + + private func openButton() -> some View { + InfoViewActionButtonLayout(image: "message.fill", title: "open") + .onTapGesture { + if openedFromChatView { + dismiss() + } else { + chatModel.chatId = chat.id + } + } + } + + // TODO show keyboard + private func messageButton() -> some View { + InfoViewActionButtonLayout(image: "message.fill", title: "message") + .onTapGesture { + if openedFromChatView { + dismiss() + } else { + if contact.chatDeleted { + var updatedContact = contact + updatedContact.chatDeleted = false + chatModel.updateContact(updatedContact) + } + chatModel.chatId = chat.id + } + } + .disabled(!contact.sendMsgEnabled) + } + + private func callButton() -> some View { + InfoViewActionButtonLayout(image: "phone.fill", title: "call") + .onTapGesture { + CallController.shared.startCall(contact, .audio) + } + .disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil) + } + + private func videoButton() -> some View { + InfoViewActionButtonLayout(image: "video.fill", title: "video") + .onTapGesture { + CallController.shared.startCall(contact, .video) + } + .disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil) + } + private func verifyCodeButton(_ code: String) -> some View { NavigationLink { VerifyCodeView( @@ -437,9 +596,9 @@ struct ChatInfoView: View { private func deleteContactButton() -> some View { Button(role: .destructive) { - showDeleteContactActionSheet = true + actionSheet = .deleteContactActionSheet } label: { - Label("Delete contact", systemImage: "trash") + Label("Delete contact", systemImage: "person.badge.minus") .foregroundColor(Color.red) } } @@ -453,17 +612,34 @@ struct ChatInfoView: View { } } - private func deleteContact(notify: Bool? = nil) { + private func deleteContact(chatDeleteMode: ChatDeleteMode) { Task { do { - try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify) + let ct = try await apiDeleteContact(id: chat.chatInfo.apiId, chatDeleteMode: chatDeleteMode) await MainActor.run { dismiss() chatModel.chatId = nil - chatModel.removeChat(chat.chatInfo.id) + switch chatDeleteMode { + case .full: + chatModel.removeChat(chat.chatInfo.id) + case .entity: + chatModel.updateContact(ct) + // dismissing sheet when opened from ChatView closes deleteContactNotice alert, + // also it makes less sense to show this alert as user is already in Chats tab + if showDeleteContactNotice && !openedFromChatView { + alert = .deleteContactNotice + } + case .messages: + logger.warning("ChatInfoView deleteContact case .messages should be unreachable") + chatModel.removeChat(chat.chatInfo.id) + chatModel.addChat(Chat( + chatInfo: .direct(contact: ct), + chatItems: [] + )) + } } } catch let error { - logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))") + logger.error("ChatInfoView deleteContact apiDeleteContact error: \(responseError(error))") let a = getErrorAlert(error, "Error deleting contact") await MainActor.run { alert = .error(title: a.title, error: a.message) @@ -548,6 +724,38 @@ struct ChatInfoView: View { } } } + + private func deleteContactNotice(_ contact: Contact) -> Alert { + return Alert( + title: Text("Contact deleted!"), + message: Text("You can still view conversation with \(contact.displayName) in the Chats tab."), + primaryButton: .default(Text("Don't show again")) { + showDeleteContactNotice = false + }, + secondaryButton: .default(Text("Ok")) + ) + } +} + +struct InfoViewActionButtonLayout: View { + var image: String + var title: LocalizedStringKey + + var body: some View { + VStack(spacing: 4) { + Image(systemName: image) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + Text(title) + .font(.caption) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.accentColor) + .background(Color(.secondarySystemGroupedBackground)) + .cornerRadius(12.0) + .frame(width: 90, height: 60) + } } func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert { @@ -580,12 +788,10 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al struct ChatInfoView_Previews: PreviewProvider { static var previews: some View { ChatInfoView( + openedFromChatView: true, chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), contact: Contact.sampleData, - connectionStats: Binding.constant(nil), - customUserProfile: Binding.constant(nil), - localAlias: "", - connectionCode: Binding.constant(nil) + localAlias: "" ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 4055ca2b28..1f51af7f64 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -23,9 +23,6 @@ struct ChatView: View { @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() @State private var keyboardVisible = false - @State private var connectionStats: ConnectionStats? - @State private var customUserProfile: Profile? - @State private var connectionCode: String? @State private var tableView: UITableView? @State private var loadingItems = false @State private var firstPage = false @@ -111,32 +108,17 @@ struct ChatView: View { ToolbarItem(placement: .principal) { 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) } - .sheet(isPresented: $showChatInfoSheet, onDismiss: { - connectionStats = nil - customUserProfile = nil - connectionCode = nil - }) { - ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: $customUserProfile, localAlias: chat.chatInfo.localAlias, connectionCode: $connectionCode) + .sheet(isPresented: $showChatInfoSheet) { + ChatInfoView( + openedFromChatView: true, + chat: chat, + contact: contact, + localAlias: chat.chatInfo.localAlias + ) } } else if case let .group(groupInfo) = cInfo { Button { diff --git a/apps/ios/Shared/Views/Chat/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Chat/Contacts/ContactListNavLink.swift new file mode 100644 index 0000000000..1a5ab1772c --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Contacts/ContactListNavLink.swift @@ -0,0 +1,210 @@ +// +// ContactListNavLink.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 06.05.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ContactListNavLink: View { + @ObservedObject var chat: Chat + @State private var contactNavLinkSheet: ContactNavLinkActionSheet? = nil + + @AppStorage(DEFAULT_SHOW_DELETE_CONTACT_NOTICE) private var showDeleteContactNotice = true + + enum ContactNavLinkActionSheet: Identifiable { + case deleteContactActionSheet + case confirmDeleteContactActionSheet(contactDeleteMode: ContactDeleteMode) + + var id: String { + switch self { + case .deleteContactActionSheet: return "deleteContactActionSheet" + case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet" + } + } + } + + var body: some View { + switch chat.chatInfo { + case let .direct(contact): + NavigationLink { + ChatInfoView( + openedFromChatView: false, + chat: chat, + contact: contact, + localAlias: chat.chatInfo.localAlias + ) + } label: { + HStack{ + ZStack(alignment: .bottomTrailing) { + ProfileImage(imageStr: contact.image, size: 38) + chatPreviewImageOverlayIcon(contact) + .padding([.bottom, .trailing], 1) + } + .padding(.trailing, 2) + + previewTitle(contact) + + Spacer() + + HStack { + if chat.chatInfo.chatSettings?.favorite ?? false { + Image(systemName: "star.fill") + .resizable() + .scaledToFill() + .frame(width: 18, height: 18) + .padding(.trailing, 1) + .foregroundColor(.secondary.opacity(0.65)) + } + if contact.contactConnIncognito { + Image(systemName: "theatermasks") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundColor(.secondary) + } + } + } + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + toggleFavoriteButton() + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + contactNavLinkSheet = .deleteContactActionSheet + } label: { + Label("Delete", systemImage: "person.badge.minus") + } + .tint(.red) + } + .actionSheet(item: $contactNavLinkSheet) { sheet in + switch(sheet) { + case .deleteContactActionSheet: + var sheetButtons: [ActionSheet.Button] = [] + sheetButtons.append( + .destructive(Text("Delete contact")) { contactNavLinkSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .full) } + ) + if !contact.chatDeleted { + sheetButtons.append( + .destructive(Text("Delete contact, keep conversation")) { contactNavLinkSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .entity) } + ) + } + sheetButtons.append(.cancel()) + return ActionSheet( + title: Text("Delete contact?"), + buttons: sheetButtons + ) + case let .confirmDeleteContactActionSheet(contactDeleteMode): + if contact.ready && contact.active { + return ActionSheet( + title: Text("Notify contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete and notify contact")) { + Task { + await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true)) + if contactDeleteMode == .entity && showDeleteContactNotice { + AlertManager.shared.showAlert(deleteContactNotice(contact)) + } + } + }, + .destructive(Text("Delete without notification")) { + Task { + await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false)) + if contactDeleteMode == .entity && showDeleteContactNotice { + AlertManager.shared.showAlert(deleteContactNotice(contact)) + } + } + }, + .cancel() + ] + ) + } else { + return ActionSheet( + title: Text("Confirm contact deletion.\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete")) { + Task { + await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false)) + if contactDeleteMode == .entity && showDeleteContactNotice { + AlertManager.shared.showAlert(deleteContactNotice(contact)) + } + } + }, + .cancel() + ] + ) + } + } + } + default: + EmptyView() + } + } + + @ViewBuilder private func previewTitle(_ contact: Contact) -> some View { + let t = Text(chat.chatInfo.chatViewName) + ( + contact.verified == true + ? verifiedIcon + t + : t + ) + .lineLimit(1) + } + + private var verifiedIcon: Text { + (Text(Image(systemName: "checkmark.shield")) + Text(" ")) + .foregroundColor(.secondary) + .baselineOffset(1) + .kerning(-2) + } + + @ViewBuilder private func chatPreviewImageOverlayIcon(_ contact: Contact) -> some View { + if !contact.active { + inactiveIcon() + } else { + EmptyView() + } + } + + private func inactiveIcon() -> some View { + Image(systemName: "multiply.circle.fill") + .foregroundColor(.secondary.opacity(0.65)) + .background(Circle().foregroundColor(Color(uiColor: .systemBackground))) + } + + private func deleteContactNotice(_ contact: Contact) -> Alert { + return Alert( + title: Text("Contact deleted!"), + message: Text("You can still view conversation with \(contact.displayName) in the Chats tab."), + primaryButton: .default(Text("Don't show again")) { + showDeleteContactNotice = false + }, + secondaryButton: .default(Text("Ok")) + ) + } + + @ViewBuilder private func toggleFavoriteButton() -> some View { + if chat.chatInfo.chatSettings?.favorite == true { + Button { + toggleChatFavorite(chat, favorite: false) + } label: { + Label("Unfav.", systemImage: "star.slash") + } + .tint(.green) + } else { + Button { + toggleChatFavorite(chat, favorite: true) + } label: { + Label("Favorite", systemImage: "star.fill") + } + .tint(.green) + } + } +} + +#Preview { + ContactListNavLink(chat: Chat.sampleData) +} diff --git a/apps/ios/Shared/Views/Chat/Contacts/ContactsView.swift b/apps/ios/Shared/Views/Chat/Contacts/ContactsView.swift new file mode 100644 index 0000000000..91b6104bb4 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Contacts/ContactsView.swift @@ -0,0 +1,199 @@ +// +// ContactsView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 06.05.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ContactsView: View { + @EnvironmentObject var chatModel: ChatModel + + @State private var searchMode = false + @FocusState private var searchFocussed + @State private var searchText = "" + + @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false + @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = true + + var body: some View { + if #available(iOS 16.0, *) { + viewBody.scrollDismissesKeyboard(.immediately) + } else { + viewBody + } + } + + private var viewBody: some View { + VStack { + contactList + } + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listStyle(.plain) + } + + @ViewBuilder private var contactList: some View { + let contactChats = contactChats() + let filteredContactChats = filteredContactChats(contactChats) + ZStack { + VStack { + List { + if !contactChats.isEmpty { + ContactsSearchBar( + searchMode: $searchMode, + searchFocussed: $searchFocussed, + searchText: $searchText + ) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity) + } + ForEach(filteredContactChats, id: \.viewId) { chat in + switch chat.chatInfo { + case let .direct(contact): + ContactListNavLink(chat: chat) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(contact.id)) + default: + EmptyView() + } + } + } + } + if filteredContactChats.isEmpty && !contactChats.isEmpty { + Text("No filtered contacts") + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .foregroundColor(.secondary) + } else if contactChats.isEmpty { + Text("No contacts") + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .foregroundColor(.secondary) + } + } + } + + private func contactChats() -> [Chat] { + return chatModel.chats.filter { chat in + switch chat.chatInfo { + case .direct: true + default: false + } + } + } + + private func filteredContactChats(_ contactChats: [Chat]) -> [Chat] { + let s = searchString() + return ( + s == "" && !showUnreadAndFavorites + ? contactChats.filter { chat in + switch chat.chatInfo { + case let .direct(contact): return contact.contactStatus != .deletedByUser + default: return false + } + } + : contactChats.filter { chat in + switch chat.chatInfo { + case let .direct(contact): + return contact.contactStatus != .deletedByUser && ( + s == "" + ? (chat.chatInfo.chatSettings?.favorite ?? false) + : (viewNameContains(contact, s) || + contact.profile.displayName.localizedLowercase.contains(s) || + contact.fullName.localizedLowercase.contains(s)) + ) + default: return false + } + } + ) + .sorted{ $0.chatInfo.displayName.lowercased() < $1.chatInfo.displayName.lowercased() } + + func searchString() -> String { + searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + } + + func viewNameContains(_ contact: Contact, _ s: String) -> Bool { + contact.chatViewName.localizedLowercase.contains(s) + } + } +} + +struct ContactsSearchBar: View { + @EnvironmentObject var m: ChatModel + @Binding var searchMode: Bool + @FocusState.Binding var searchFocussed: Bool + @Binding var searchText: String + @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false + + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 12) { + HStack(spacing: 4) { + Image(systemName: "magnifyingglass") + TextField("Search", text: $searchText) + .focused($searchFocussed) + .frame(maxWidth: .infinity) + if !searchText.isEmpty { + Image(systemName: "xmark.circle.fill") + .onTapGesture { + searchText = "" + } + } + } + .padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7)) + .foregroundColor(.secondary) + .background(Color(.tertiarySystemFill)) + .cornerRadius(10.0) + + if searchFocussed { + Text("Cancel") + .foregroundColor(.accentColor) + .onTapGesture { + searchText = "" + searchFocussed = false + } + } else if m.chats.count > 0 { + toggleFilterButton() + } + } + } + .onChange(of: searchFocussed) { sf in + withAnimation { searchMode = sf } + } + } + + 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 ? .accentColor : .secondary) + .frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16) + .onTapGesture { + showUnreadAndFavorites = !showUnreadAndFavorites + } + } + } +} + +struct ContactsView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.chats = [ + Chat( + chatInfo: ChatInfo.sampleData.direct, + chatItems: [] + ) + ] + return Group { + ContactsView() + .environmentObject(chatModel) + ContactsView() + .environmentObject(ChatModel()) + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index a24608b7e7..c5e1ccd788 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -62,10 +62,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 } @@ -78,18 +79,35 @@ struct GroupMemberInfoView: View { List { groupMemberInfoHeader(member) .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + + HStack { + if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) { + knownDirectChatButton(chat) + Spacer() + callButton(contact) + Spacer() + videoButton(contact) + } 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() + } + Spacer() + InfoViewActionButtonLayout(image: "phone.fill", title: "call") + .disabled(true) + Spacer() + InfoViewActionButtonLayout(image: "video.fill", title: "video") + .disabled(true) + } + } + .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 { @@ -249,58 +267,71 @@ 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.fill", 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.fill", 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.fill", 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 callButton(_ contact: Contact) -> some View { + InfoViewActionButtonLayout(image: "phone.fill", title: "call") + .onTapGesture { + CallController.shared.startCall(contact, .audio) + } + .disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil) + } + + private func videoButton(_ contact: Contact) -> some View { + InfoViewActionButtonLayout(image: "video.fill", title: "video") + .onTapGesture { + CallController.shared.startCall(contact, .video) + } + .disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil) } 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..4a5cdb9a9a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -63,7 +63,6 @@ struct ChatHelp: View { struct ChatHelp_Previews: PreviewProvider { static var previews: some View { - @State var showSettings = false - return ChatHelp(showSettings: $showSettings) + return ChatHelp(showSettings: Binding.constant(false)) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index efe254323e..5086595e73 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -32,11 +32,25 @@ 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 contactNavLinkSheet: ContactNavLinkActionSheet? = nil @State private var showConnectContactViaAddressDialog = false @State private var inProgress = false @State private var progressByTimeout = false + @AppStorage(DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE) private var showDeleteConversationNotice = true + + enum ContactNavLinkActionSheet: Identifiable { + case deleteContactActionSheet + case confirmDeleteContactActionSheet + + var id: String { + switch self { + case .deleteContactActionSheet: return "deleteContactActionSheet" + case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet" + } + } + } + var body: some View { Group { switch chat.chatInfo { @@ -67,12 +81,12 @@ 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: rowHeights[dynamicTypeSize]) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { - showDeleteContactActionSheet = true + contactNavLinkSheet = .deleteContactActionSheet } label: { Label("Delete", systemImage: "trash") } @@ -100,7 +114,7 @@ struct ChatListNavLink: View { } Button { if contact.ready || !contact.active { - showDeleteContactActionSheet = true + contactNavLinkSheet = .deleteContactActionSheet } else { AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact)) } @@ -112,24 +126,53 @@ struct ChatListNavLink: View { .frame(height: rowHeights[dynamicTypeSize]) } } - .actionSheet(isPresented: $showDeleteContactActionSheet) { - if contact.ready && 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() - ] - ) - } else { - return ActionSheet( - title: Text("Delete contact?\nThis cannot be undone!"), - buttons: [ - .destructive(Text("Delete")) { Task { await deleteChat(chat) } }, - .cancel() - ] - ) + .actionSheet(item: $contactNavLinkSheet) { sheet in + switch(sheet) { + case .deleteContactActionSheet: + if contact.contactStatus == .deletedByUser { + return ActionSheet( + title: Text("Delete conversation?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete conversation")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } }, + .cancel() + ] + ) + } else { + return ActionSheet( + title: Text("Delete contact?"), + buttons: [ + .destructive(Text("Delete contact")) { contactNavLinkSheet = .confirmDeleteContactActionSheet }, + .destructive(Text("Only delete conversation")) { + Task { + await deleteChatContact(chat, chatDeleteMode: .messages) + if showDeleteConversationNotice { + AlertManager.shared.showAlert(deleteConversationNotice(contact)) + } + } + }, + .cancel() + ] + ) + } + case .confirmDeleteContactActionSheet: + if contact.ready && contact.active { + return ActionSheet( + title: Text("Notify contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete and notify contact")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: true)) } }, + .destructive(Text("Delete without notification")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } }, + .cancel() + ] + ) + } else { + return ActionSheet( + title: Text("Confirm contact deletion.\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } }, + .cancel() + ] + ) + } } } } @@ -437,6 +480,17 @@ struct ChatListNavLink: View { ) } + private func deleteConversationNotice(_ contact: Contact) -> Alert { + return Alert( + title: Text("Conversation deleted!"), + message: Text("You can still send messages to \(contact.displayName) from the Contacts tab. "), + primaryButton: .default(Text("Don't show again")) { + showDeleteConversationNotice = false + }, + secondaryButton: .default(Text("Ok")) + ) + } + private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert { Alert( title: Text("Delete pending connection"), diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 6bf63bb2e3..f9d389025e 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -11,16 +11,15 @@ import SimpleXChat struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel - @Binding var showSettings: Bool + @State private var searchMode = false @FocusState private var searchFocussed @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 = true var body: some View { if #available(iOS 16.0, *) { @@ -31,44 +30,10 @@ struct ChatListView: View { } private var viewBody: some View { - ZStack(alignment: .topLeading) { - NavStackCompat( - isActive: Binding( - get: { chatModel.chatId != nil }, - set: { _ in } - ), - destination: chatView - ) { - VStack { - if chatModel.chats.isEmpty { - onboardingButtons() - } - chatListView - } - } - if userPickerVisible { - Rectangle().fill(.white.opacity(0.001)).onTapGesture { - withAnimation { - userPickerVisible.toggle() - } - } - } - UserPicker( - showSettings: $showSettings, - showConnectDesktop: $showConnectDesktop, - userPickerVisible: $userPickerVisible - ) - } - .sheet(isPresented: $showConnectDesktop) { - ConnectDesktopView() - } - } - - private var chatListView: some View { VStack { chatList } - .onDisappear() { withAnimation { userPickerVisible = false } } + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .refreshable { AlertManager.shared.showAlert(Alert( title: Text("Reconnect servers?"), @@ -86,58 +51,6 @@ struct ChatListView: View { )) } .listStyle(.plain) - .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(searchMode) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - let user = chatModel.currentUser ?? User.sampleData - ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel)) - .padding(.trailing, 4) - let allRead = chatModel.users - .filter { u in !u.user.activeUser && !u.user.hidden } - .allSatisfy { u in u.unreadCount == 0 } - if !allRead { - unreadBadge(size: 12) - } - } - .onTapGesture { - if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 { - withAnimation { - userPickerVisible.toggle() - } - } else { - showSettings = true - } - } - } - ToolbarItem(placement: .principal) { - HStack(spacing: 4) { - Text("Chats") - .font(.headline) - if chatModel.chats.count > 0 { - toggleFilterButton() - } - } - .frame(maxWidth: .infinity, alignment: .center) - } - ToolbarItem(placement: .navigationBarTrailing) { - switch chatModel.chatRunning { - case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption) - case .some(false): chatStoppedIcon() - case .none: EmptyView() - } - } - } - } - - private func toggleFilterButton() -> some View { - Button { - showUnreadAndFavorites = !showUnreadAndFavorites - } label: { - Image(systemName: "line.3.horizontal.decrease.circle" + (showUnreadAndFavorites ? ".fill" : "")) - .foregroundColor(.accentColor) - } } @ViewBuilder private var chatList: some View { @@ -153,11 +66,13 @@ struct ChatListView: View { searchShowingSimplexLink: $searchShowingSimplexLink, searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink ) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .listRowSeparator(.hidden) .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)) } @@ -171,7 +86,9 @@ struct ChatListView: View { } } if cs.isEmpty && !chatModel.chats.isEmpty { - Text("No filtered chats").foregroundColor(.secondary) + Text("No filtered chats") + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .foregroundColor(.secondary) } } } @@ -182,66 +99,24 @@ struct ChatListView: View { .foregroundColor(.accentColor) } - 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(Color.accentColor) - .frame(width: 20, height: 10) - .padding(.trailing, 12) - - connectButton("Tap to start a new chat") { - newChatMenuOption = .newContact - } - - Spacer() - Text("You have no chats") - .foregroundColor(.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(Color.accentColor) - .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).onAppear { - loadChat(chat: chat) - } - } - } - private func filteredChats() -> [Chat] { if let linkChatId = searchChatFilteredBySimplexLink { return chatModel.chats.filter { $0.id == linkChatId } } else { let s = searchString() return s == "" && !showUnreadAndFavorites - ? chatModel.chats + ? chatModel.chats.filter { chat in !chat.chatInfo.chatDeleted } : 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 && ( + 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) @@ -282,9 +157,9 @@ struct ChatListSearchBar: View { @Binding var searchShowingSimplexLink: Bool @Binding var searchChatFilteredBySimplexLink: String? @State private var ignoreSearchTextChange = false - @State private var showScanCodeSheet = 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 { VStack(spacing: 12) { @@ -301,26 +176,6 @@ struct ChatListSearchBar: View { .onTapGesture { searchText = "" } - } else if !searchFocussed { - HStack(spacing: 24) { - if m.pasteboardHasStrings { - Image(systemName: "doc") - .onTapGesture { - if let str = UIPasteboard.general.string { - searchText = str - } - } - } - - Image(systemName: "qrcode") - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - .onTapGesture { - showScanCodeSheet = true - } - } - .padding(.trailing, 2) } } .padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7)) @@ -335,13 +190,10 @@ struct ChatListSearchBar: View { searchText = "" searchFocussed = false } + } else if m.chats.count > 0 { + toggleFilterButton() } } - Divider() - } - .sheet(isPresented: $showScanCodeSheet) { - NewChatView(selection: .connect, showQRCodeScanner: true) - .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) // fixes .refreshable in ChatListView affecting nested view } .onChange(of: searchFocussed) { sf in withAnimation { searchMode = sf } @@ -376,6 +228,21 @@ struct ChatListSearchBar: View { } } + 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 ? .accentColor : .secondary) + .frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16) + .onTapGesture { + showUnreadAndFavorites = !showUnreadAndFavorites + } + } + } + private func connect(_ link: String) { planAndConnect( link, @@ -389,17 +256,6 @@ struct ChatListSearchBar: View { } } -func chatStoppedIcon() -> some View { - Button { - AlertManager.shared.showAlertMsg( - title: "Chat is stopped", - message: "You can start chat via app Settings / Database or by restarting the app" - ) - } label: { - Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red) - } -} - struct ChatListView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() @@ -419,9 +275,9 @@ struct ChatListView_Previews: PreviewProvider { ] return Group { - ChatListView(showSettings: Binding.constant(false)) + ChatListView() .environmentObject(chatModel) - ChatListView(showSettings: Binding.constant(false)) + ChatListView() .environmentObject(ChatModel()) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index fe8fd8b28e..1096481f42 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -204,7 +204,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(.accentColor) } else if !contact.ready && contact.activeConn != nil { diff --git a/apps/ios/Shared/Views/Home/HomeView.swift b/apps/ios/Shared/Views/Home/HomeView.swift new file mode 100644 index 0000000000..ec961db59e --- /dev/null +++ b/apps/ios/Shared/Views/Home/HomeView.swift @@ -0,0 +1,219 @@ +// +// HomeView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 01.05.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +enum HomeTab { + case contacts + case chats +} + +struct HomeView: View { + @EnvironmentObject var chatModel: ChatModel + @Binding var showSettings: Bool + @State private var homeTab: HomeTab = .chats + @State private var userPickerVisible = false + @State private var showConnectDesktop = false + @State private var newChatMenuOption: NewChatMenuOption? = nil + + @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = true + + var body: some View { + ZStack(alignment: .bottomLeading) { + NavStackCompat( + isActive: Binding( + get: { chatModel.chatId != nil }, + set: { _ in } + ), + destination: chatView + ) { + VStack { + switch homeTab { + case .contacts: + contactsView() + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Contacts") + case .chats: + chatListView() + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Chats") + } + } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + settingsButton() + Spacer() + contactsButton() + Spacer() + chatsButton() + Spacer() + newChatButton() + } + } + } + + if userPickerVisible { + Rectangle().fill(.white.opacity(0.001)).onTapGesture { + withAnimation { + userPickerVisible.toggle() + } + } + } + UserPicker( + showSettings: $showSettings, + showConnectDesktop: $showConnectDesktop, + userPickerVisible: $userPickerVisible + ) + } + .sheet(isPresented: $showConnectDesktop) { + ConnectDesktopView() + } + } + + @ViewBuilder private func settingsButton() -> some View { + let user = chatModel.currentUser ?? User.sampleData + let multiUser = chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 + Button { + if multiUser { + withAnimation { + userPickerVisible.toggle() + } + } else { + showSettings = true + } + } label: { + VStack(spacing: 2) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: user.image, size: 38, color: Color(uiColor: .quaternaryLabel)) + .padding(.top, 2) + .padding(.trailing, 3) + let allRead = chatModel.users + .filter { u in !u.user.activeUser && !u.user.hidden } + .allSatisfy { u in u.unreadCount == 0 } + if !allRead { + userUnreadBadge(size: 12) + } + } + } + } + .foregroundColor(.secondary) + } + + private func userUnreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View { + Circle() + .frame(width: size, height: size) + .foregroundColor(.accentColor) + } + + private func contactsButton() -> some View { + Button { + homeTab = .contacts + } label: { + iconLabel(homeTab == .contacts ? "book.fill" : "book", "Contacts") + } + .foregroundColor(.secondary) + } + + private func chatsButton() -> some View { + Button { + homeTab = .chats + } label: { + iconLabel(homeTab == .chats ? "message.fill" : "message", "Chats") + } + .foregroundColor(.secondary) + } + + @ViewBuilder private func newChatButton() -> some View { + if case .some(false) = chatModel.chatRunning { + chatsStoppedButton() + } else { + Menu { + Button { + newChatMenuOption = .newGroup + } label: { + Text("Create group") + } + Button { + newChatMenuOption = .scanPaste + } label: { + Text("Scan / Paste link") + } + Button { + newChatMenuOption = .newContact + } label: { + Text("Add contact") + } + } label: { + iconLabel("square.and.pencil", "New chat") + } + .sheet(item: $newChatMenuOption) { opt in + switch opt { + case .newContact: NewChatView(selection: .invite) + case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true) + case .newGroup: AddGroupView() + } + } + } + } + + func chatsStoppedButton() -> some View { + Button { + AlertManager.shared.showAlertMsg( + title: "Chat is stopped", + message: "You can start chat via app Settings / Database or by restarting the app" + ) + } label: { + VStack(spacing: 4) { + Image(systemName: "exclamationmark.octagon.fill") + .resizable() + .scaledToFit() + .foregroundColor(.red) + .frame(width: 24, height: 24) + Text("Stopped") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private func iconLabel(_ image: String, _ title: LocalizedStringKey) -> some View { + VStack(spacing: 2) { + Image(systemName: image) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + Text(title) + .font(.caption2) + } + .padding(.top, 3) + } + + @ViewBuilder private func contactsView() -> some View { + ContactsView() + .padding(.vertical, oneHandUI ? 1 : 0) + } + + @ViewBuilder private func chatListView() -> some View { + // TODO reverse scale effect for swipe actions + ChatListView() + .padding(.vertical, oneHandUI ? 1 : 0) + } + + @ViewBuilder private func chatView() -> some View { + if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) { + ChatView(chat: chat).onAppear { + loadChat(chat: chat) + } + } + } +} + +#Preview { + HomeView(showSettings: Binding.constant(false)) +} diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index c3452ce18d..3be1095bfd 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -10,6 +10,7 @@ import SwiftUI enum NewChatMenuOption: Identifiable { case newContact + case scanPaste case newGroup var id: Self { self } @@ -25,6 +26,11 @@ struct NewChatMenuButton: View { } label: { Text("Add contact") } + Button { + newChatMenuOption = .scanPaste + } label: { + Text("Scan / Paste link") + } Button { newChatMenuOption = .newGroup } label: { @@ -39,6 +45,7 @@ struct NewChatMenuButton: View { .sheet(item: $newChatMenuOption) { opt in switch opt { case .newContact: NewChatView(selection: .invite) + case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true) case .newGroup: AddGroupView() } } diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index ba192b333c..b5114203bb 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -43,6 +43,7 @@ extension AppSettings { if let val = androidCallOnLockScreen { def.setValue(val.rawValue, forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN) } if let val = iosCallKitEnabled { callKitEnabledGroupDefault.set(val) } if let val = iosCallKitCallsInRecents { def.setValue(val, forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) } + if let val = oneHandUI { def.setValue(val, forKey: DEFAULT_ONE_HAND_UI) } } public static var current: AppSettings { @@ -67,6 +68,7 @@ extension AppSettings { c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!) c.iosCallKitEnabled = callKitEnabledGroupDefault.get() c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) + 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 b91d2c9369..ccc1b174d7 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -25,6 +25,7 @@ struct AppearanceSettings: View { @State private var userInterfaceStyle = getUserInterfaceStyleDefault() @State private var uiTintColor = getUIAccentColorDefault() @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner + @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = true var body: some View { VStack{ @@ -39,6 +40,12 @@ struct AppearanceSettings: View { } } + Section("Interface") { + settingsRow("hand.wave") { + Toggle("One-hand UI", isOn: $oneHandUI) + } + } + Section("App icon") { HStack { updateAppIcon(image: "icon-light", icon: nil, tapped: $iconLightTapped) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index e532448a90..32e1ecedbe 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -46,6 +46,7 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" 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" @@ -60,6 +61,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 ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen" @@ -89,6 +92,7 @@ let appDefaults: [String: Any] = [ DEFAULT_ACCENT_COLOR_BLUE: 1.000, DEFAULT_USER_INTERFACE_STYLE: 0, DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner, + DEFAULT_ONE_HAND_UI: true, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true, @@ -99,7 +103,9 @@ let appDefaults: [String: Any] = [ DEFAULT_CONFIRM_REMOTE_SESSIONS: false, DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true, DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true, - ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue + ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue, + DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE: true, + DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true ] // not used anymore @@ -446,9 +452,8 @@ struct SettingsView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.currentUser = User.sampleData - @State var showSettings = false - return SettingsView(showSettings: $showSettings) + return SettingsView(showSettings: Binding.constant(false)) .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 8c1a3bf4e1..15d877d425 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -403,6 +403,6 @@ public func chatPasswordHash(_ pwd: String, _ salt: String) -> String { struct UserProfilesView_Previews: PreviewProvider { static var previews: some View { - UserProfilesView(showSettings: Binding.constant(true)) + UserProfilesView(showSettings: Binding.constant(false)) } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 30345d6895..7032ccf71b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -155,6 +155,7 @@ 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; }; 6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; }; 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; }; + 642CE4952BE2651E00AD7757 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642CE4942BE2651E00AD7757 /* HomeView.swift */; }; 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; }; 6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; }; 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; }; @@ -172,9 +173,11 @@ 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; + 6485F7C62BE8DB6500FAE413 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6485F7C52BE8DB6500FAE413 /* ContactsView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; + 64A596452BE90DEC00B69266 /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A596442BE90DEC00B69266 /* ContactListNavLink.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; @@ -450,6 +453,7 @@ 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = ""; }; 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextInvitingContactMemberView.swift; sourceTree = ""; }; 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = ""; }; + 642CE4942BE2651E00AD7757 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = ""; }; 6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = ""; }; 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = ""; }; @@ -467,10 +471,12 @@ 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; + 6485F7C52BE8DB6500FAE413 /* ContactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; + 64A596442BE90DEC00B69266 /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; @@ -562,6 +568,7 @@ 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( + 642CE4932BE2650500AD7757 /* Home */, 5CB0BA8C282711BC00B3292C /* Onboarding */, 3C714775281C080100CB4D4B /* Call */, 5C971E1F27AEBF7000C8A3CE /* Helpers */, @@ -581,6 +588,7 @@ 5C5F4AC227A5E9AF00B51EF1 /* Chat */ = { isa = PBXGroup; children = ( + 6485F7C42BE8DB4F00FAE413 /* Contacts */, 6440CA01288AEC770062C672 /* Group */, 5CE4407427ADB657007B033A /* ChatItem */, 5CEACCE527DE977C000BD591 /* ComposeMessage */, @@ -899,6 +907,14 @@ path = Database; sourceTree = ""; }; + 642CE4932BE2650500AD7757 /* Home */ = { + isa = PBXGroup; + children = ( + 642CE4942BE2651E00AD7757 /* HomeView.swift */, + ); + path = Home; + sourceTree = ""; + }; 6440CA01288AEC770062C672 /* Group */ = { isa = PBXGroup; children = ( @@ -913,6 +929,15 @@ path = Group; sourceTree = ""; }; + 6485F7C42BE8DB4F00FAE413 /* Contacts */ = { + isa = PBXGroup; + children = ( + 6485F7C52BE8DB6500FAE413 /* ContactsView.swift */, + 64A596442BE90DEC00B69266 /* ContactListNavLink.swift */, + ); + path = Contacts; + sourceTree = ""; + }; 8C7D94982B8894D300B7B9E1 /* Migration */ = { isa = PBXGroup; children = ( @@ -1148,6 +1173,7 @@ 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */, + 64A596452BE90DEC00B69266 /* ContactListNavLink.swift in Sources */, 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */, 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */, @@ -1168,6 +1194,7 @@ 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */, 5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */, 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */, + 6485F7C62BE8DB6500FAE413 /* ContactsView.swift in Sources */, 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, @@ -1212,6 +1239,7 @@ 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, 5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */, + 642CE4952BE2651E00AD7757 /* HomeView.swift in Sources */, 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 3aa610e4af..fe53e42de3 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -97,7 +97,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) @@ -251,11 +251,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)) \(encodeJSON(chatDeleteMode))" 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))" @@ -988,6 +984,12 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? { } } +public enum ChatDeleteMode: Codable { + case full(notify: Bool) + case entity(notify: Bool) + case messages +} + public enum ConnectionPlan: Decodable { case invitationLink(invitationLinkPlan: InvitationLinkPlan) case contactAddress(contactAddressPlan: ContactAddressPlan) @@ -2004,6 +2006,7 @@ public struct AppSettings: Codable, Equatable { public var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil public var iosCallKitEnabled: Bool? = nil public var iosCallKitCallsInRecents: Bool? = nil + public var oneHandUI: Bool? = nil public func prepareForExport() -> AppSettings { var empty = AppSettings() @@ -2027,6 +2030,7 @@ public struct AppSettings: Codable, Equatable { if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen } if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled } if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents } + if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI } return empty } @@ -2050,7 +2054,8 @@ public struct AppSettings: Codable, Equatable { confirmDBUpgrades: false, androidCallOnLockScreen: AppSettingsLockScreenCalls.show, iosCallKitEnabled: true, - iosCallKitCallsInRecents: false + iosCallKitCallsInRecents: false, + oneHandUI: true ) } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 24aca0dd18..7f0f5cc732 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1287,6 +1287,15 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } } + public var chatDeleted: Bool { + get { + switch self { + case let .direct(contact): return contact.chatDeleted + default: return false + } + } + } + public var sendMsgEnabled: Bool { get { switch self { @@ -1504,6 +1513,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { var chatTs: Date? var contactGroupMemberId: Int64? var contactGrpInvSent: Bool + public var chatDeleted: Bool public var id: ChatId { get { "@\(contactId)" } } public var apiId: Int64 { get { contactId } } @@ -1565,13 +1575,15 @@ public struct Contact: Identifiable, Decodable, NamedChat { mergedPreferences: ContactUserPreferences.sampleData, createdAt: .now, updatedAt: .now, - contactGrpInvSent: false + contactGrpInvSent: false, + chatDeleted: false ) } public enum ContactStatus: String, Decodable { case active = "active" case deleted = "deleted" + case deletedByUser = "deletedByUser" } public struct ContactRef: Decodable, Equatable {