diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index f2e67c4aa5..46c36ab197 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -28,6 +28,17 @@ struct ContentView: View { @State private var showWhatsNew = false @State private var showChooseLAMode = false @State private var showSetPasscode = false + @State private var chatListActionSheet: ChatListActionSheet? = nil + + private enum ChatListActionSheet: Identifiable { + case connectViaUrl(action: ConnReqType, link: String) + + var id: String { + switch self { + case .connectViaUrl: return "connectViaUrl \(link)" + } + } + } var body: some View { ZStack { @@ -80,6 +91,11 @@ struct ContentView: View { if case .onboardingComplete = step, chatModel.currentUser != nil { mainView() + .actionSheet(item: $chatListActionSheet) { sheet in + switch sheet { + case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link) + } + } } else { OnboardingView(onboarding: step) } @@ -132,7 +148,9 @@ struct ContentView: View { } } prefShowLANotice = true + connectViaUrl() } + .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } .sheet(isPresented: $showWhatsNew) { WhatsNewView() } @@ -265,36 +283,38 @@ struct ContentView: View { secondaryButton: .cancel() ) } -} -func connectViaUrl() { - let m = ChatModel.shared - if let url = m.appOpenUrl { - m.appOpenUrl = nil - AlertManager.shared.showAlert(connectViaUrlAlert(url)) + func connectViaUrl() { + let m = ChatModel.shared + if let url = m.appOpenUrl { + m.appOpenUrl = nil + var path = url.path + logger.debug("ContentView.connectViaUrl path: \(path)") + if (path == "/contact" || path == "/invitation") { + path.removeFirst() + let action: ConnReqType = path == "contact" ? .contact : .invitation + let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + chatListActionSheet = .connectViaUrl(action: action, link: link) + } else { + AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid"))) + } + } } -} -func connectViaUrlAlert(_ url: URL) -> Alert { - var path = url.path - logger.debug("ChatListView.connectViaUrlAlert path: \(path)") - if (path == "/contact" || path == "/invitation") { - path.removeFirst() - let action: ConnReqType = path == "contact" ? .contact : .invitation - let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet { let title: LocalizedStringKey - if case .contact = action { title = "Connect via contact link?" } - else { title = "Connect via one-time link?" } - return Alert( + switch action { + case .contact: title = "Connect via contact link" + case .invitation: title = "Connect via one-time link" + } + return ActionSheet( title: Text(title), - message: Text("Your profile will be sent to the contact that you received this link from"), - primaryButton: .default(Text("Connect")) { - connectViaLink(link) - }, - secondaryButton: .cancel() + buttons: [ + .default(Text("Use current profile")) { connectViaLink(link, incognito: false) }, + .default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) }, + .cancel() + ] ) - } else { - return Alert(title: Text("Error: URL is invalid")) } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 3179b4f862..c3331c822a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -43,9 +43,8 @@ final class ChatModel: ObservableObject { @Published var tokenStatus: NtfTknStatus? @Published var notificationMode = NotificationsMode.off @Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get() - @Published var incognito: Bool = incognitoGroupDefault.get() // pending notification actions - @Published var ntfContactRequest: ChatId? + @Published var ntfContactRequest: NTFContactRequest? @Published var ntfCallInvitationAction: (ChatId, NtfCallAction)? // current WebRTC call @Published var callInvitations: Dictionary = [:] @@ -589,6 +588,11 @@ final class ChatModel: ObservableObject { } } +struct NTFContactRequest { + var incognito: Bool + var chatId: String +} + struct UnreadChatItemCounts { var totalBelow: Int var unreadBelow: Int diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index ad41703c91..c7a51a5f1a 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -12,6 +12,7 @@ import UIKit import SimpleXChat let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT" +let ntfActionAcceptContactIncognito = "NTF_ACT_ACCEPT_CONTACT_INCOGNITO" let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL" let ntfActionRejectCall = "NTF_ACT_REJECT_CALL" @@ -41,12 +42,13 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { userId != chatModel.currentUser?.userId { changeActiveUser(userId, viewPwd: nil) } - if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, + if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito), let chatId = content.userInfo["chatId"] as? String { + let incognito = action == ntfActionAcceptContactIncognito if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { - Task { await acceptContactRequest(contactRequest) } + Task { await acceptContactRequest(incognito: incognito, contactRequest: contactRequest) } } else { - chatModel.ntfContactRequest = chatId + chatModel.ntfContactRequest = NTFContactRequest(incognito: incognito, chatId: chatId) } } else if let (chatId, ntfAction) = ntfCallAction(content, action) { if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) { @@ -134,11 +136,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { UNUserNotificationCenter.current().setNotificationCategories([ UNNotificationCategory( identifier: ntfCategoryContactRequest, - actions: [UNNotificationAction( - identifier: ntfActionAcceptContact, - title: NSLocalizedString("Accept", comment: "accept contact request via notification"), - options: .foreground - )], + actions: [ + UNNotificationAction( + identifier: ntfActionAcceptContact, + title: NSLocalizedString("Accept", comment: "accept contact request via notification"), + options: .foreground + ), UNNotificationAction( + identifier: ntfActionAcceptContactIncognito, + title: NSLocalizedString("Accept incognito", comment: "accept contact request via notification"), + options: .foreground + ) + ], intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: NSLocalizedString("New contact request", comment: "notification") ), diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index c99f176b78..62600b2825 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -252,12 +252,6 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } -func apiSetIncognito(incognito: Bool) throws { - let r = chatSendCmdSync(.setIncognito(incognito: incognito)) - if case .cmdOk = r { return } - throw r -} - func apiExportArchive(config: ArchiveConfig) async throws { try await sendCommandOkResp(.apiExportArchive(config: config)) } @@ -564,19 +558,25 @@ func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCo return nil } -func apiAddContact() async -> String? { +func apiAddContact(incognito: Bool) async -> (String, PendingContactConnection)? { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiAddContact: no current user") return nil } - let r = await chatSendCmd(.apiAddContact(userId: userId), bgTask: false) - if case let .invitation(_, connReqInvitation) = r { return connReqInvitation } + let r = await chatSendCmd(.apiAddContact(userId: userId, incognito: incognito), bgTask: false) + if case let .invitation(_, connReqInvitation, connection) = r { return (connReqInvitation, connection) } AlertManager.shared.showAlert(connectionErrorAlert(r)) return nil } -func apiConnect(connReq: String) async -> ConnReqType? { - let (connReqType, alert) = await apiConnect_(connReq: connReq) +func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? { + let r = await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito)) + if case let .connectionIncognitoUpdated(_, toConnection) = r { return toConnection } + throw r +} + +func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? { + let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq) if let alert = alert { AlertManager.shared.showAlert(alert) return nil @@ -585,12 +585,12 @@ func apiConnect(connReq: String) async -> ConnReqType? { } } -func apiConnect_(connReq: String) async -> (ConnReqType?, Alert?) { +func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiConnect: no current user") return (nil, nil) } - let r = await chatSendCmd(.apiConnect(userId: userId, connReq: connReq)) + let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq)) switch r { case .sentConfirmation: return (.invitation, nil) case .sentInvitation: return (.contact, nil) @@ -766,8 +766,8 @@ func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContac } } -func apiAcceptContactRequest(contactReqId: Int64) async -> Contact? { - let r = await chatSendCmd(.apiAcceptContact(contactReqId: contactReqId)) +func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? { + let r = await chatSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId)) let am = AlertManager.shared if case let .acceptingContactRequest(_, contact) = r { return contact } @@ -875,8 +875,8 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? { } } -func acceptContactRequest(_ contactRequest: UserContactRequest) async { - if let contact = await apiAcceptContactRequest(contactReqId: contactRequest.apiId) { +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 { ChatModel.shared.replaceChat(contactRequest.id, chat) } } @@ -1110,7 +1110,6 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(getXFTPCfg()) - try apiSetIncognito(incognito: incognitoGroupDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() if m.currentUser == nil { diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 5184808bd7..13e681ae25 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -139,10 +139,10 @@ struct SimpleXApp: App { let chat = chatModel.getChat(id) { loadChat(chat: chat) } - if let chatId = chatModel.ntfContactRequest { + if let ncr = chatModel.ntfContactRequest { chatModel.ntfContactRequest = nil - if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { - Task { await acceptContactRequest(contactRequest) } + if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo { + Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) } } } } catch let error { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 5bbc64e165..3b0861feb1 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -143,7 +143,12 @@ struct ChatInfoView: View { if let customUserProfile = customUserProfile { Section("Incognito") { - infoRow("Your random profile", customUserProfile.chatViewName) + HStack { + Text("Your random profile") + Spacer() + Text(customUserProfile.chatViewName) + .foregroundStyle(.indigo) + } } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 0f125ca8f0..75e81790a2 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -57,7 +57,7 @@ struct GroupChatInfoView: View { addOrEditWelcomeMessage() } groupPreferencesButton($groupInfo) - if members.filter { $0.memberCurrent }.count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + if members.filter({ $0.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { sendReceiptsOption() } else { sendReceiptsOptionDisabled() diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 47a2131d30..6842e93f05 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -19,6 +19,7 @@ struct GroupMemberInfoView: View { @State private var connectionCode: String? = nil @State private var newRole: GroupMemberRole = .member @State private var alert: GroupMemberInfoViewAlert? + @State private var connectToMemberDialog: Bool = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var justOpened = true @@ -28,7 +29,6 @@ struct GroupMemberInfoView: View { case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert - case connectViaMemberAddressAlert(contactLink: String) case connRequestSentAlert(type: ConnReqType) case error(title: LocalizedStringKey, error: LocalizedStringKey) case other(alert: Alert) @@ -40,7 +40,6 @@ struct GroupMemberInfoView: View { case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" - case .connectViaMemberAddressAlert: return "connectViaMemberAddressAlert" case .connRequestSentAlert: return "connRequestSentAlert" case let .error(title, _): return "error \(title)" case let .other(alert): return "other \(alert)" @@ -144,7 +143,7 @@ struct GroupMemberInfoView: View { connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } || connStats.ratchetSyncSendProhibited ) - if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } { + if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { Button("Abort changing address") { alert = .abortSwitchAddressAlert } @@ -203,7 +202,6 @@ struct GroupMemberInfoView: View { case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) - case let .connectViaMemberAddressAlert(contactLink): return connectViaMemberAddressAlert(contactLink) case let .connRequestSentAlert(type): return connReqSentAlert(type) case let .error(title, error): return Alert(title: Text(title), message: Text(error)) case let .other(alert): return alert @@ -213,26 +211,19 @@ struct GroupMemberInfoView: View { func connectViaAddressButton(_ contactLink: String) -> some View { Button { - alert = .connectViaMemberAddressAlert(contactLink: contactLink) + connectToMemberDialog = true } label: { Label("Connect", systemImage: "link") } + .confirmationDialog("Connect directly", isPresented: $connectToMemberDialog, titleVisibility: .visible) { + Button("Use current profile") { connectViaAddress(incognito: false, contactLink: contactLink) } + Button("Use new incognito profile") { connectViaAddress(incognito: true, contactLink: contactLink) } + } } - func connectViaMemberAddressAlert(_ contactLink: String) -> Alert { - return Alert( - title: Text("Connect directly?"), - message: Text("Сonnection request will be sent to this group member."), - primaryButton: .default(Text("Connect")) { - connectViaAddress(contactLink) - }, - secondaryButton: .cancel() - ) - } - - func connectViaAddress(_ contactLink: String) { + func connectViaAddress(incognito: Bool, contactLink: String) { Task { - let (connReqType, connectAlert) = await apiConnect_(connReq: contactLink) + let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink) if let connReqType = connReqType { alert = .connRequestSentAlert(type: connReqType) } else if let connectAlert = connectAlert { diff --git a/apps/ios/Shared/Views/Chat/ScanCodeView.swift b/apps/ios/Shared/Views/Chat/ScanCodeView.swift index d5b6edf906..09861fa50b 100644 --- a/apps/ios/Shared/Views/Chat/ScanCodeView.swift +++ b/apps/ios/Shared/Views/Chat/ScanCodeView.swift @@ -19,7 +19,7 @@ struct ScanCodeView: View { VStack(alignment: .leading) { CodeScannerView(codeTypes: [.qr], completion: processQRCode) .aspectRatio(1, contentMode: .fit) - .border(.gray) + .cornerRadius(12) Text("Scan security code from your contact's app.") .padding(.top) } diff --git a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift index 21269f8435..75e31c26ed 100644 --- a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift +++ b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift @@ -64,7 +64,7 @@ struct VerifyCodeView: View { HStack { NavigationLink { ScanCodeView(connectionVerified: $connectionVerified, verify: verify) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.large) .navigationTitle("Scan code") } label: { Label("Scan code", systemImage: "qrcode") diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 2521c919a1..025c765a72 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -222,9 +222,15 @@ struct ChatListNavLink: View { ContactRequestView(contactRequest: contactRequest, chat: chat) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { - Task { await acceptContactRequest(contactRequest) } - } label: { Label("Accept", systemImage: chatModel.incognito ? "theatermasks" : "checkmark") } - .tint(chatModel.incognito ? .indigo : .accentColor) + Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } + } label: { Label("Accept", systemImage: "checkmark") } + .tint(.accentColor) + Button { + Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } + } label: { + Label("Accept incognito", systemImage: "theatermasks") + } + .tint(.indigo) Button { AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest)) } label: { @@ -234,9 +240,10 @@ struct ChatListNavLink: View { } .frame(height: rowHeights[dynamicTypeSize]) .onTapGesture { showContactRequestDialog = true } - .confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) { - Button(chatModel.incognito ? "Accept incognito" : "Accept contact") { Task { await acceptContactRequest(contactRequest) } } - Button("Reject contact (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } } + .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) } } } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 03dd241087..eb0a5cba68 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -60,8 +60,6 @@ struct ChatListView: View { chatList } } - .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } - .onAppear() { connectViaUrl() } .onDisappear() { withAnimation { userPickerVisible = false } } .refreshable { AlertManager.shared.showAlert(Alert( @@ -108,11 +106,6 @@ struct ChatListView: View { } ToolbarItem(placement: .principal) { HStack(spacing: 4) { - if (chatModel.incognito) { - Image(systemName: "theatermasks") - .foregroundColor(.indigo) - .padding(.trailing, 8) - } Text("Chats") .font(.headline) if chatModel.chats.count > 0 { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index d6cd977b9e..4ddf75a1b3 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -41,11 +41,9 @@ struct ChatPreviewView: View { ZStack(alignment: .topTrailing) { chatMessagePreview(cItem) - if case .direct = chat.chatInfo { - chatStatusImage() - .padding(.top, 24) - .frame(maxWidth: .infinity, alignment: .trailing) - } + chatStatusImage() + .padding(.top, 26) + .frame(maxWidth: .infinity, alignment: .trailing) } .padding(.trailing, 8) @@ -59,12 +57,9 @@ struct ChatPreviewView: View { @ViewBuilder private func chatPreviewImageOverlayIcon() -> some View { if case let .group(groupInfo) = chat.chatInfo { switch (groupInfo.membership.memberStatus) { - case .memLeft: - groupInactiveIcon() - case .memRemoved: - groupInactiveIcon() - case .memGroupDeleted: - groupInactiveIcon() + case .memLeft: groupInactiveIcon() + case .memRemoved: groupInactiveIcon() + case .memGroupDeleted: groupInactiveIcon() default: EmptyView() } } else { @@ -74,7 +69,7 @@ struct ChatPreviewView: View { @ViewBuilder private func groupInactiveIcon() -> some View { Image(systemName: "multiply.circle.fill") - .foregroundColor(.secondary) + .foregroundColor(.secondary.opacity(0.65)) .background(Circle().foregroundColor(Color(uiColor: .systemBackground))) } @@ -198,10 +193,7 @@ struct ChatPreviewView: View { @ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View { groupInfo.membership.memberIncognito ? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)") - : (chatModel.incognito - ? chatPreviewInfoText("join as \(chatModel.currentUser?.profile.displayName ?? "yourself")") - : chatPreviewInfoText("you are invited to group") - ) + : chatPreviewInfoText("you are invited to group") } @ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View { @@ -229,7 +221,7 @@ struct ChatPreviewView: View { switch chat.chatInfo { case let .direct(contact): switch (chatModel.contactNetworkStatus(contact)) { - case .connected: EmptyView() + case .connected: incognitoIcon(chat.chatInfo.incognito) case .error: Image(systemName: "exclamationmark.circle") .resizable() @@ -240,11 +232,23 @@ struct ChatPreviewView: View { ProgressView() } default: - EmptyView() + incognitoIcon(chat.chatInfo.incognito) } } } +@ViewBuilder func incognitoIcon(_ incognito: Bool) -> some View { + if incognito { + Image(systemName: "theatermasks") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundColor(.secondary) + } else { + EmptyView() + } +} + func unreadCountText(_ n: Int) -> Text { Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "") } diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift index 7d850719d6..3e42d2f207 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift @@ -15,6 +15,7 @@ struct ContactConnectionInfo: View { @State var contactConnection: PendingContactConnection @State private var alert: CCInfoAlert? @State private var localAlias = "" + @State private var showIncognitoSheet = false @FocusState private var aliasTextFieldFocused: Bool enum CCInfoAlert: Identifiable { @@ -31,19 +32,14 @@ struct ContactConnectionInfo: View { var body: some View { NavigationView { - List { + let v = List { Group { - Text(contactConnection.initiated ? "You invited your contact" : "You accepted connection") + Text(contactConnection.initiated ? "You invited a contact" : "You accepted connection") .font(.largeTitle) .bold() - .padding(.bottom, 16) + .padding(.bottom) Text(contactConnectionText(contactConnection)) - .padding(.bottom, 16) - - if let connReqInv = contactConnection.connReqInv { - OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInv) - } } .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -65,10 +61,16 @@ struct ContactConnectionInfo: View { if contactConnection.initiated, let connReqInv = contactConnection.connReqInv { - oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInv) + QRCode(uri: connReqInv) + incognitoEnabled() + shareLinkButton(connReqInv) + oneTimeLinkLearnMoreButton() } else { + incognitoEnabled() oneTimeLinkLearnMoreButton() } + } footer: { + sharedProfileInfo(contactConnection.incognito) } Section { @@ -80,6 +82,14 @@ struct ContactConnectionInfo: View { } } } + if #available(iOS 16, *) { + v + } else { + // navigationBarHidden is added conditionally, + // because the view jumps in iOS 17 if this is added, + // and on iOS 16+ it is hidden without it. + v.navigationBarHidden(true) + } } .alert(item: $alert) { _alert in switch _alert { @@ -128,6 +138,30 @@ struct ContactConnectionInfo: View { ) : "You will be connected when your contact's device is online, please wait or check later!" } + + @ViewBuilder private func incognitoEnabled() -> some View { + if contactConnection.incognito { + ZStack(alignment: .leading) { + Image(systemName: "theatermasks.fill") + .frame(maxWidth: 24, maxHeight: 24, alignment: .center) + .foregroundColor(Color.indigo) + .font(.system(size: 14)) + HStack(spacing: 6) { + Text("Incognito") + Image(systemName: "info.circle") + .foregroundColor(.accentColor) + .font(.system(size: 14)) + } + .onTapGesture { + showIncognitoSheet = true + } + .padding(.leading, 36) + } + .sheet(isPresented: $showIncognitoSheet) { + IncognitoHelp() + } + } + } } struct ContactConnectionInfo_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift index 1cd50c606d..d21f347881 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift @@ -58,10 +58,14 @@ struct ContactConnectionView: View { } .padding(.bottom, 2) - Text(contactConnection.description) - .frame(alignment: .topLeading) - .padding(.horizontal, 8) - .padding(.bottom, 2) + ZStack(alignment: .topTrailing) { + Text(contactConnection.description) + .frame(maxWidth: .infinity, alignment: .leading) + incognitoIcon(contactConnection.incognito) + .padding(.top, 26) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 8) Spacer() } diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index c40f672877..c5c062a6ec 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -24,7 +24,7 @@ struct ContactRequestView: View { Text(contactRequest.chatViewName) .font(.title3) .fontWeight(.bold) - .foregroundColor(chatModel.incognito ? .indigo : .accentColor) + .foregroundColor(.accentColor) .padding(.leading, 8) .frame(alignment: .topLeading) Spacer() diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 44de3c4987..31b6b64f32 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -12,38 +12,92 @@ import SimpleXChat struct AddContactView: View { @EnvironmentObject private var chatModel: ChatModel - var contactConnection: PendingContactConnection? = nil + @Binding var contactConnection: PendingContactConnection? var connReqInvitation: String + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false var body: some View { - List { - OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInvitation) - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - - Section("1-time link") { - oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInvitation) + VStack { + List { + Section { + if connReqInvitation != "" { + QRCode(uri: connReqInvitation) + } else { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(2) + .frame(maxWidth: .infinity) + .padding(.vertical) + } + IncognitoToggle(incognitoEnabled: $incognitoDefault) + .disabled(contactConnection == nil) + shareLinkButton(connReqInvitation) + oneTimeLinkLearnMoreButton() + } header: { + Text("1-time link") + } footer: { + sharedProfileInfo(incognitoDefault) + } } } .onAppear { chatModel.connReqInv = connReqInvitation } + .onChange(of: incognitoDefault) { incognito in + Task { + do { + if let contactConn = contactConnection, + let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) { + await MainActor.run { + contactConnection = conn + ChatModel.shared.updateContactConnection(conn) + } + } + } catch { + logger.error("apiSetConnectionIncognito error: \(responseError(error))") + } + } + } } } -@ViewBuilder func oneTimeLinkSection(contactConnection: PendingContactConnection? = nil, connReqInvitation: String) -> some View { - if connReqInvitation != "" { - QRCode(uri: connReqInvitation) - } else { - ProgressView() - .progressViewStyle(.circular) - .scaleEffect(2) - .frame(maxWidth: .infinity) - .padding(.vertical) +struct IncognitoToggle: View { + @Binding var incognitoEnabled: Bool + @State private var showIncognitoSheet = false + + var body: some View { + ZStack(alignment: .leading) { + Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks") + .frame(maxWidth: 24, maxHeight: 24, alignment: .center) + .foregroundColor(incognitoEnabled ? Color.indigo : .secondary) + .font(.system(size: 14)) + Toggle(isOn: $incognitoEnabled) { + HStack(spacing: 6) { + Text("Incognito") + Image(systemName: "info.circle") + .foregroundColor(.accentColor) + .font(.system(size: 14)) + } + .onTapGesture { + showIncognitoSheet = true + } + } + .padding(.leading, 36) + } + .sheet(isPresented: $showIncognitoSheet) { + IncognitoHelp() + } } - shareLinkButton(connReqInvitation) - oneTimeLinkLearnMoreButton() } -private func shareLinkButton(_ connReqInvitation: String) -> some View { +func sharedProfileInfo(_ incognito: Bool) -> Text { + let name = ChatModel.shared.currentUser?.displayName ?? "" + return Text( + incognito + ? "A new random profile will be shared." + : "Your profile **\(name)** will be shared." + ) +} + +func shareLinkButton(_ connReqInvitation: String) -> some View { Button { showShareSheet(items: [connReqInvitation]) } label: { @@ -65,26 +119,11 @@ func oneTimeLinkLearnMoreButton() -> some View { } } -struct OneTimeLinkProfileText: View { - @EnvironmentObject private var chatModel: ChatModel - var contactConnection: PendingContactConnection? = nil - var connReqInvitation: String - - var body: some View { - HStack { - if (contactConnection?.incognito ?? chatModel.incognito) { - Image(systemName: "theatermasks").foregroundColor(.indigo) - Text("A random profile will be sent to your contact") - } else { - Image(systemName: "info.circle").foregroundColor(.secondary) - Text("Your chat profile will be sent to your contact") - } - } - } -} - struct AddContactView_Previews: PreviewProvider { static var previews: some View { - AddContactView(connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D") + AddContactView( + contactConnection: Binding.constant(PendingContactConnection.getSampleData()), + connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D" + ) } } diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 247b91a04a..8df37bb560 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -47,21 +47,13 @@ struct AddGroupView: View { .padding(.vertical, 4) Text("The group is fully decentralized – it is visible only to the members.") .padding(.bottom, 4) - if (m.incognito) { - HStack { - Image(systemName: "info.circle").foregroundColor(.orange).font(.footnote) - Spacer().frame(width: 8) - Text("Incognito mode is not supported here - your main profile will be sent to group members").font(.footnote) - } - .padding(.bottom) - } else { - HStack { - Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) - Spacer().frame(width: 8) - Text("Your chat profile will be sent to group members").font(.footnote) - } - .padding(.bottom) + + HStack { + Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) + Spacer().frame(width: 8) + Text("Your chat profile will be sent to group members").font(.footnote) } + .padding(.bottom) ZStack(alignment: .center) { ZStack(alignment: .topTrailing) { diff --git a/apps/ios/Shared/Views/NewChat/CreateLinkView.swift b/apps/ios/Shared/Views/NewChat/CreateLinkView.swift index 71daf88a8c..0b9cfe7a17 100644 --- a/apps/ios/Shared/Views/NewChat/CreateLinkView.swift +++ b/apps/ios/Shared/Views/NewChat/CreateLinkView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat enum CreateLinkTab { case oneTime @@ -24,6 +25,7 @@ struct CreateLinkView: View { @EnvironmentObject var m: ChatModel @State var selection: CreateLinkTab @State var connReqInvitation: String = "" + @State var contactConnection: PendingContactConnection? = nil @State private var creatingConnReq = false var viaNavLink = false @@ -39,7 +41,7 @@ struct CreateLinkView: View { private func createLinkView() -> some View { TabView(selection: $selection) { - AddContactView(connReqInvitation: connReqInvitation) + AddContactView(contactConnection: $contactConnection, connReqInvitation: connReqInvitation) .tabItem { Label( connReqInvitation == "" @@ -56,7 +58,7 @@ struct CreateLinkView: View { .tag(CreateLinkTab.longTerm) } .onChange(of: selection) { _ in - if case .oneTime = selection, connReqInvitation == "" && !creatingConnReq { + if case .oneTime = selection, connReqInvitation == "", contactConnection == nil && !creatingConnReq { createInvitation() } } @@ -69,12 +71,14 @@ struct CreateLinkView: View { private func createInvitation() { creatingConnReq = true Task { - let connReq = await apiAddContact() - await MainActor.run { - if let connReq = connReq { + if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { + await MainActor.run { connReqInvitation = connReq + contactConnection = pcc m.connReqInv = connReq - } else { + } + } else { + await MainActor.run { creatingConnReq = false } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 3486ab6ef8..a727ad6be0 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -10,13 +10,13 @@ import SwiftUI import SimpleXChat enum NewChatAction: Identifiable { - case createLink(link: String) + case createLink(link: String, connection: PendingContactConnection) case connectViaLink case createGroup var id: String { switch self { - case let .createLink(link): return "createLink \(link)" + case let .createLink(link, _): return "createLink \(link)" case .connectViaLink: return "connectViaLink" case .createGroup: return "createGroup" } @@ -41,8 +41,8 @@ struct NewChatButton: View { } .sheet(item: $actionSheet) { sheet in switch sheet { - case let .createLink(link): - CreateLinkView(selection: .oneTime, connReqInvitation: link) + case let .createLink(link, pcc): + CreateLinkView(selection: .oneTime, connReqInvitation: link, contactConnection: pcc) case .connectViaLink: ConnectViaLinkView() case .createGroup: AddGroupView() } @@ -51,8 +51,8 @@ struct NewChatButton: View { func addContactAction() { Task { - if let connReq = await apiAddContact() { - actionSheet = .createLink(link: connReq) + if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { + actionSheet = .createLink(link: connReq, connection: pcc) } } } @@ -63,9 +63,9 @@ enum ConnReqType: Equatable { case invitation } -func connectViaLink(_ connectionLink: String, _ dismiss: DismissAction? = nil) { +func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) { Task { - if let connReqType = await apiConnect(connReq: connectionLink) { + if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { DispatchQueue.main.async { dismiss?() AlertManager.shared.showAlert(connReqSentAlert(connReqType)) @@ -100,12 +100,12 @@ func checkCRDataGroup(_ crData: CReqClientData) -> Bool { return crData.type == "group" && crData.groupLinkId != nil } -func groupLinkAlert(_ connectionLink: String) -> Alert { +func groupLinkAlert(_ connectionLink: String, incognito: Bool) -> Alert { return Alert( title: Text("Connect via group link?"), message: Text("You will join a group this link refers to and connect to its group members."), - primaryButton: .default(Text("Connect")) { - connectViaLink(connectionLink) + primaryButton: .default(Text(incognito ? "Connect incognito" : "Connect")) { + connectViaLink(connectionLink, incognito: incognito) }, secondaryButton: .cancel() ) diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index de390ebad9..3894092e32 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -7,76 +7,77 @@ // import SwiftUI +import SimpleXChat struct PasteToConnectView: View { - @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @State private var connectionLink: String = "" + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false + @FocusState private var linkEditorFocused: Bool var body: some View { - ScrollView { - VStack(alignment: .leading) { - Text("Connect via link") - .font(.largeTitle) - .bold() - .fixedSize(horizontal: false, vertical: true) - .padding(.vertical) - Text("Paste the link you received into the box below to connect with your contact.") - .padding(.bottom, 4) - if (chatModel.incognito) { - HStack { - Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote) - Spacer().frame(width: 8) - Text("A random profile will be sent to the contact that you received this link from").font(.footnote) + List { + Text("Connect via link") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .onTapGesture { linkEditorFocused = false } + + Section { + linkEditor() + + Button { + if connectionLink == "" { + connectionLink = UIPasteboard.general.string ?? "" + } else { + connectionLink = "" } - .padding(.bottom) - } else { - HStack { - Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) - Spacer().frame(width: 8) - Text("Your profile will be sent to the contact that you received this link from").font(.footnote) + } label: { + if connectionLink == "" { + settingsRow("doc.plaintext") { Text("Paste") } + } else { + settingsRow("multiply") { Text("Clear") } } - .padding(.bottom) + } + + Button { + connect() + } label: { + settingsRow("link") { Text("Connect") } + } + .disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil) + + IncognitoToggle(incognitoEnabled: $incognitoDefault) + } footer: { + sharedProfileInfo(incognitoDefault) + + Text("\n\n") + + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + } + } + } + + private func linkEditor() -> some View { + ZStack { + Group { + if connectionLink.isEmpty { + TextEditor(text: Binding.constant(NSLocalizedString("Paste the link you received to connect with your contact…", comment: "placeholder"))) + .foregroundColor(.secondary) + .disabled(true) } TextEditor(text: $connectionLink) .onSubmit(connect) .textInputAutocapitalization(.never) .disableAutocorrection(true) - .allowsTightening(false) - .frame(height: 180) - .overlay( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) - ) - - HStack(spacing: 20) { - if connectionLink == "" { - Button { - connectionLink = UIPasteboard.general.string ?? "" - } label: { - Label("Paste", systemImage: "doc.plaintext") - } - } else { - Button { - connectionLink = "" - } label: { - Label("Clear", systemImage: "multiply") - } - - } - Spacer() - Button(action: connect, label: { - Label("Connect", systemImage: "link") - }) - .disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil) - } - .frame(height: 48) - .padding(.bottom) - - Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + .focused($linkEditorFocused) } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .allowsTightening(false) + .padding(.horizontal, -5) + .padding(.top, -8) + .frame(height: 180, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -85,9 +86,9 @@ struct PasteToConnectView: View { if let crData = parseLinkQueryData(link), checkCRDataGroup(crData) { dismiss() - AlertManager.shared.showAlert(groupLinkAlert(link)) + AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault)) } else { - connectViaLink(link, dismiss) + connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault) } } } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index 2213dff203..cff94ef2aa 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -7,11 +7,12 @@ // import SwiftUI +import SimpleXChat import CodeScanner struct ScanToConnectView: View { - @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false var body: some View { ScrollView { @@ -19,34 +20,35 @@ struct ScanToConnectView: View { Text("Scan QR code") .font(.largeTitle) .bold() + .fixedSize(horizontal: false, vertical: true) .padding(.vertical) - if (chatModel.incognito) { - HStack { - Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote) - Spacer().frame(width: 8) - Text("A random profile will be sent to your contact").font(.footnote) - } - .padding(.bottom) - } else { - HStack { - Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) - Spacer().frame(width: 8) - Text("Your chat profile will be sent to your contact").font(.footnote) - } - .padding(.bottom) + + CodeScannerView(codeTypes: [.qr], completion: processQRCode) + .aspectRatio(1, contentMode: .fit) + .cornerRadius(12) + + IncognitoToggle(incognitoEnabled: $incognitoDefault) + .padding(.horizontal) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .systemBackground)) + ) + .padding(.top) + + Group { + sharedProfileInfo(incognitoDefault) + + Text("\n\n") + + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") } - ZStack { - CodeScannerView(codeTypes: [.qr], completion: processQRCode) - .aspectRatio(1, contentMode: .fit) - .border(.gray) - } - .padding(.bottom) - Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") - .padding(.bottom) + .font(.footnote) + .foregroundColor(.secondary) + .padding(.horizontal) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + .background(Color(.systemGroupedBackground)) } func processQRCode(_ resp: Result) { @@ -55,9 +57,9 @@ struct ScanToConnectView: View { if let crData = parseLinkQueryData(r.string), checkCRDataGroup(crData) { dismiss() - AlertManager.shared.showAlert(groupLinkAlert(r.string)) + AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault)) } else { - Task { connectViaLink(r.string, dismiss) } + Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) } } case let .failure(e): logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") diff --git a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift index 3652dec054..20dadb7954 100644 --- a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift +++ b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift @@ -18,10 +18,9 @@ struct IncognitoHelp: View { ScrollView { VStack(alignment: .leading) { Group { - Text("Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.") + Text("Incognito mode protects your privacy by using a new random profile for each contact.") Text("It allows having many anonymous connections without any shared data between them in a single chat profile.") Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.") - Text("To find the profile used for an incognito connection, tap the contact or group name on top of the chat.") } .padding(.bottom) } diff --git a/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift b/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift index c0ad4e3e18..ffdbd1b07e 100644 --- a/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift +++ b/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift @@ -21,11 +21,10 @@ struct ScanProtocolServer: View { .font(.largeTitle) .bold() .padding(.vertical) - ZStack { - CodeScannerView(codeTypes: [.qr], completion: processQRCode) - .aspectRatio(1, contentMode: .fit) - .border(.gray) - } + CodeScannerView(codeTypes: [.qr], completion: processQRCode) + .aspectRatio(1, contentMode: .fit) + .cornerRadius(12) + .padding(.top) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 7ca18692a8..72cfaaac48 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -131,7 +131,6 @@ struct SettingsView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var sceneDelegate: SceneDelegate @Binding var showSettings: Bool - @State private var settingsSheet: SettingsSheet? var body: some View { ZStack { @@ -161,8 +160,6 @@ struct SettingsView: View { settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") } } - incognitoRow() - NavigationLink { UserAddressView(shareViaProfile: chatModel.currentUser!.addressShared) .navigationTitle("SimpleX address") @@ -298,39 +295,6 @@ struct SettingsView: View { } .navigationTitle("Your settings") } - .sheet(item: $settingsSheet) { sheet in - switch sheet { - case .incognitoInfo: IncognitoHelp() - } - } - } - - @ViewBuilder private func incognitoRow() -> some View { - ZStack(alignment: .leading) { - Image(systemName: chatModel.incognito ? "theatermasks.fill" : "theatermasks") - .frame(maxWidth: 24, maxHeight: 24, alignment: .center) - .foregroundColor(chatModel.incognito ? Color.indigo : .secondary) - Toggle(isOn: $chatModel.incognito) { - HStack(spacing: 6) { - Text("Incognito") - Image(systemName: "info.circle") - .foregroundColor(.accentColor) - .font(.system(size: 14)) - } - .onTapGesture { - settingsSheet = .incognitoInfo - } - } - .onChange(of: chatModel.incognito) { incognito in - incognitoGroupDefault.set(incognito) - do { - try apiSetIncognito(incognito: incognito) - } catch { - logger.error("apiSetIncognito: cannot set incognito \(responseError(error))") - } - } - .padding(.leading, indent) - } } private func chatDatabaseRow() -> some View { @@ -351,12 +315,6 @@ struct SettingsView: View { } } - private enum SettingsSheet: Identifiable { - case incognitoInfo - - var id: SettingsSheet { get { self } } - } - private enum NotificationAlert { case enable case error(LocalizedStringKey, String) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 43ba3ab323..95050cae83 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -219,7 +219,6 @@ func startChat() -> DBMigrationResult? { let justStarted = try apiStartChat() chatStarted = true if justStarted { - try apiSetIncognito(incognito: incognitoGroupDefault.get()) chatLastStartGroupDefault.set(Date.now) Task { await receiveMessages() } } @@ -352,12 +351,6 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } -func apiSetIncognito(incognito: Bool) throws { - let r = sendSimpleXCmd(.setIncognito(incognito: incognito)) - if case .cmdOk = r { return } - throw r -} - func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { guard apiGetActiveUser() != nil else { logger.debug("no active user") diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index a33f6496d7..3a64a0bc7c 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -32,7 +32,6 @@ public enum ChatCommand { case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) case apiSetXFTPConfig(config: XFTPFileConfig?) - case setIncognito(incognito: Bool) case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage @@ -83,8 +82,9 @@ public enum ChatCommand { case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) case apiVerifyContact(contactId: Int64, connectionCode: String?) case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) - case apiAddContact(userId: Int64) - case apiConnect(userId: Int64, connReq: String) + case apiAddContact(userId: Int64, incognito: Bool) + case apiSetConnectionIncognito(connId: Int64, incognito: Bool) + case apiConnect(userId: Int64, incognito: Bool, connReq: String) case apiDeleteChat(type: ChatType, id: Int64) case apiClearChat(type: ChatType, id: Int64) case apiListContacts(userId: Int64) @@ -97,7 +97,7 @@ public enum ChatCommand { case apiShowMyAddress(userId: Int64) case apiSetProfileAddress(userId: Int64, on: Bool) case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?) - case apiAcceptContact(contactReqId: Int64) + case apiAcceptContact(incognito: Bool, contactReqId: Int64) case apiRejectContact(contactReqId: Int64) // WebRTC calls case apiSendCallInvitation(contact: Contact, callType: CallType) @@ -148,7 +148,6 @@ public enum ChatCommand { } else { return "/_xftp off" } - case let .setIncognito(incognito): return "/incognito \(onOff(incognito))" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" @@ -213,8 +212,9 @@ public enum ChatCommand { case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)" case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)" case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" - case let .apiAddContact(userId): return "/_connect \(userId)" - case let .apiConnect(userId, connReq): return "/_connect \(userId) \(connReq)" + case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" + case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" + case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case let .apiListContacts(userId): return "/_contacts \(userId)" @@ -227,7 +227,7 @@ public enum ChatCommand { case let .apiShowMyAddress(userId): return "/_show_address \(userId)" case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))" case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))" - case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" + case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)" case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))" case let .apiRejectCall(contact): return "/_call reject @\(contact.apiId)" @@ -274,7 +274,6 @@ public enum ChatCommand { case .setTempFolder: return "setTempFolder" case .setFilesFolder: return "setFilesFolder" case .apiSetXFTPConfig: return "apiSetXFTPConfig" - case .setIncognito: return "setIncognito" case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" @@ -326,6 +325,7 @@ public enum ChatCommand { case .apiVerifyContact: return "apiVerifyContact" case .apiVerifyGroupMember: return "apiVerifyGroupMember" case .apiAddContact: return "apiAddContact" + case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" case .apiConnect: return "apiConnect" case .apiDeleteChat: return "apiDeleteChat" case .apiClearChat: return "apiClearChat" @@ -448,7 +448,8 @@ public enum ChatResponse: Decodable, Error { case contactCode(user: User, contact: Contact, connectionCode: String) case groupMemberCode(user: User, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) case connectionVerified(user: User, verified: Bool, expectedCode: String) - case invitation(user: User, connReqInvitation: String) + case invitation(user: User, connReqInvitation: String, connection: PendingContactConnection) + case connectionIncognitoUpdated(user: User, toConnection: PendingContactConnection) case sentConfirmation(user: User) case sentInvitation(user: User) case contactAlreadyExists(user: User, contact: Contact) @@ -582,6 +583,7 @@ public enum ChatResponse: Decodable, Error { case .groupMemberCode: return "groupMemberCode" case .connectionVerified: return "connectionVerified" case .invitation: return "invitation" + case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" case .sentConfirmation: return "sentConfirmation" case .sentInvitation: return "sentInvitation" case .contactAlreadyExists: return "contactAlreadyExists" @@ -713,7 +715,8 @@ public enum ChatResponse: Decodable, Error { case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") - case let .invitation(u, connReqInvitation): return withUser(u, connReqInvitation) + case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation) + case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case .sentConfirmation: return noDetails case .sentInvitation: return noDetails case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) @@ -1449,6 +1452,7 @@ public enum ChatErrorType: Decodable { case serverProtocol case agentCommandError(message: String) case invalidFileDescription(message: String) + case connectionIncognitoChangeProhibited case internalError(message: String) case exception(message: String) } diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 65907d89f3..335ba06183 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -29,7 +29,7 @@ let GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE = "networkEnableKeepAlive" let GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE = "networkTCPKeepIdle" let GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL = "networkTCPKeepIntvl" let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt" -let GROUP_DEFAULT_INCOGNITO = "incognito" +public let GROUP_DEFAULT_INCOGNITO = "incognito" let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase" let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase" public let GROUP_DEFAULT_CONFIRM_DB_UPGRADES = "confirmDBUpgrades"