diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json new file mode 100644 index 0000000000..d3a15f9a33 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Flux_logo_blue_white.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png new file mode 100644 index 0000000000..e1d6dda4fe Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png differ diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Contents.json new file mode 100644 index 0000000000..ad18e60448 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Flux_logo_blue.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Flux_logo_blue.png b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Flux_logo_blue.png new file mode 100644 index 0000000000..87f1373d75 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Flux_logo_blue.png differ diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json new file mode 100644 index 0000000000..16686bdf80 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Flux_symbol_blue-white.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png new file mode 100644 index 0000000000..0793b0ee85 Binary files /dev/null and b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png differ diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index e85f6a664a..652258415e 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -9,6 +9,18 @@ import SwiftUI import Intents import SimpleXChat +private enum NoticesSheet: Identifiable { + case whatsNew(updatedConditions: Bool) + case updatedConditions + + var id: String { + switch self { + case .whatsNew: return "whatsNew" + case .updatedConditions: return "updatedConditions" + } + } +} + struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared @@ -30,7 +42,8 @@ struct ContentView: View { @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false - @State private var showWhatsNew = false + @State private var noticesShown = false + @State private var noticesSheetItem: NoticesSheet? = nil @State private var showChooseLAMode = false @State private var showSetPasscode = false @State private var waitingForOrPassedAuth = true @@ -261,8 +274,15 @@ struct ContentView: View { alertManager.showAlert(laNoticeAlert()) } else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if !showWhatsNew { - showWhatsNew = shouldShowWhatsNew() + if !noticesShown { + let showWhatsNew = shouldShowWhatsNew() + let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false + noticesShown = showWhatsNew || showUpdatedConditions + if showWhatsNew { + noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions) + } else if showUpdatedConditions { + noticesSheetItem = .updatedConditions + } } } } @@ -270,8 +290,22 @@ struct ContentView: View { connectViaUrl() } .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } - .sheet(isPresented: $showWhatsNew) { - WhatsNewView() + .sheet(item: $noticesSheetItem) { item in + switch item { + case let .whatsNew(updatedConditions): + WhatsNewView(updatedConditions: updatedConditions) + .modifier(ThemedBackground()) + .if(updatedConditions) { v in + v.task { await setConditionsNotified_() } + } + case .updatedConditions: + UsageConditionsView( + currUserServers: Binding.constant([]), + userServers: Binding.constant([]) + ) + .modifier(ThemedBackground(grouped: true)) + .task { await setConditionsNotified_() } + } } if chatModel.setDeliveryReceipts { SetDeliveryReceiptsView() @@ -283,6 +317,15 @@ struct ContentView: View { .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) } + private func setConditionsNotified_() async { + do { + let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId + try await setConditionsNotified(conditionsId: conditionsId) + } catch let error { + logger.error("setConditionsNotified error: \(responseError(error))") + } + } + private func processUserActivity(_ activity: NSUserActivity) { let intent = activity.interaction?.intent if let intent = intent as? INStartCallIntent { diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e6970d4728..4bc5917ed7 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -193,6 +193,8 @@ final class ChatModel: ObservableObject { @Published var draft: ComposeState? @Published var draftChatId: String? @Published var networkInfo = UserNetworkInfo(networkType: .other, online: true) + // usage conditions + @Published var conditions: ServerOperatorConditions = .empty var messageDelivery: Dictionary Void> = [:] diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 9297aa7898..13b11568d8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -500,18 +500,6 @@ func apiDeleteToken(token: DeviceToken) async throws { try await sendCommandOkResp(.apiDeleteToken(token: token)) } -func getUserProtoServers(_ serverProtocol: ServerProtocol) throws -> UserProtoServers { - let userId = try currentUserId("getUserProtoServers") - let r = chatSendCmdSync(.apiGetUserProtoServers(userId: userId, serverProtocol: serverProtocol)) - if case let .userProtoServers(_, servers) = r { return servers } - throw r -} - -func setUserProtoServers(_ serverProtocol: ServerProtocol, servers: [ServerCfg]) async throws { - let userId = try currentUserId("setUserProtoServers") - try await sendCommandOkResp(.apiSetUserProtoServers(userId: userId, serverProtocol: serverProtocol, servers: servers)) -} - func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> { let userId = try currentUserId("testProtoServer") let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server)) @@ -524,6 +512,65 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail throw r } +func getServerOperators() throws -> ServerOperatorConditions { + let r = chatSendCmdSync(.apiGetServerOperators) + if case let .serverOperatorConditions(conditions) = r { return conditions } + logger.error("getServerOperators error: \(String(describing: r))") + throw r +} + +func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions { + let r = await chatSendCmd(.apiSetServerOperators(operators: operators)) + if case let .serverOperatorConditions(conditions) = r { return conditions } + logger.error("setServerOperators error: \(String(describing: r))") + throw r +} + +func getUserServers() async throws -> [UserOperatorServers] { + let userId = try currentUserId("getUserServers") + let r = await chatSendCmd(.apiGetUserServers(userId: userId)) + if case let .userServers(_, userServers) = r { return userServers } + logger.error("getUserServers error: \(String(describing: r))") + throw r +} + +func setUserServers(userServers: [UserOperatorServers]) async throws { + let userId = try currentUserId("setUserServers") + let r = await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers)) + if case .cmdOk = r { return } + logger.error("setUserServers error: \(String(describing: r))") + throw r +} + +func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] { + let userId = try currentUserId("validateServers") + let r = await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers)) + if case let .userServersValidation(_, serverErrors) = r { return serverErrors } + logger.error("validateServers error: \(String(describing: r))") + throw r +} + +func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) { + let r = await chatSendCmd(.apiGetUsageConditions) + if case let .usageConditions(usageConditions, conditionsText, acceptedConditions) = r { return (usageConditions, conditionsText, acceptedConditions) } + logger.error("getUsageConditions error: \(String(describing: r))") + throw r +} + +func setConditionsNotified(conditionsId: Int64) async throws { + let r = await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId)) + if case .cmdOk = r { return } + logger.error("setConditionsNotified error: \(String(describing: r))") + throw r +} + +func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions { + let r = await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)) + if case let .serverOperatorConditions(conditions) = r { return conditions } + logger.error("acceptConditions error: \(String(describing: r))") + throw r +} + func getChatItemTTL() throws -> ChatItemTTL { let userId = try currentUserId("getChatItemTTL") return try chatItemTTLResponse(chatSendCmdSync(.apiGetChatItemTTL(userId: userId))) @@ -1558,6 +1605,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() + m.conditions = try getServerOperators() if m.currentUser == nil { onboardingStageDefault.set(.step1_SimpleXInfo) privacyDeliveryReceiptsSet.set(true) @@ -1602,7 +1650,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws { } } -func startChat(refreshInvitations: Bool = true) throws { +func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws { logger.debug("startChat") let m = ChatModel.shared try setNetworkConfig(getNetCfg()) @@ -1621,13 +1669,15 @@ func startChat(refreshInvitations: Bool = true) throws { if let token = m.deviceToken { registerToken(token: token) } - withAnimation { - let savedOnboardingStage = onboardingStageDefault.get() - m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1 - ? .step3_CreateSimpleXAddress - : savedOnboardingStage - if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() { - m.setDeliveryReceipts = true + if !onboarding { + withAnimation { + let savedOnboardingStage = onboardingStageDefault.get() + m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1 + ? .step3_ChooseServerOperators + : savedOnboardingStage + if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() { + m.setDeliveryReceipts = true + } } } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 7b24995f62..b18e9295b9 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -31,9 +31,21 @@ enum UserPickerSheet: Identifiable { } } +class SaveableSettings: ObservableObject { + @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: []) +} + +struct ServerSettings { + public var currUserServers: [UserOperatorServers] + public var userServers: [UserOperatorServers] + public var serverErrors: [UserServersError] +} + struct UserPickerSheetView: View { let sheet: UserPickerSheet @EnvironmentObject var chatModel: ChatModel + @StateObject private var ss = SaveableSettings() + @State private var loaded = false var body: some View { @@ -76,6 +88,21 @@ struct UserPickerSheetView: View { { loaded = true } ) } + .onDisappear { + if serversCanBeSaved( + ss.servers.currUserServers, + ss.servers.userServers, + ss.servers.serverErrors + ) { + showAlert( + title: NSLocalizedString("Save servers?", comment: "alert title"), + buttonTitle: NSLocalizedString("Save", comment: "alert button"), + buttonAction: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) }, + cancelButton: true + ) + } + } + .environmentObject(ss) } } @@ -94,6 +121,7 @@ struct ChatListView: View { @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false + @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial var body: some View { @@ -276,12 +304,6 @@ struct ChatListView: View { .padding(.top, oneHandUI ? 8 : 0) .id("searchBar") } - if !oneHandUICardShown { - OneHandUICard() - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } if #available(iOS 16.0, *) { ForEach(cs, id: \.viewId) { chat in ChatListNavLink(chat: chat) @@ -307,6 +329,20 @@ struct ChatListView: View { .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id)) } } + if !oneHandUICardShown { + OneHandUICard() + .padding(.vertical, 6) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + if !addressCreationCardShown { + AddressCreationCard() + .padding(.vertical, 6) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } } .listStyle(.plain) .onChange(of: chatModel.chatId) { currentChatId in diff --git a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift index 636d165114..059f24cc82 100644 --- a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift +++ b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift @@ -32,7 +32,6 @@ struct OneHandUICard: View { .background(theme.appColors.sentMessage) .cornerRadius(12) .frame(height: dynamicSize(userFont).rowHeight) - .padding(.vertical, 12) .alert(isPresented: $showOneHandUIAlert) { Alert( title: Text("Reachable chat toolbar"), diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift index 22ea78f27b..b87b84ebc0 100644 --- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift +++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift @@ -491,15 +491,6 @@ struct SMPServerSummaryView: View { Section("Server address") { Text(summary.smpServer) .textSelection(.enabled) - if summary.known == true { - NavigationLink { - ProtocolServersView(serverProtocol: .smp) - .navigationTitle("Your SMP servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - Text("Open server settings") - } - } } if let stats = summary.stats { @@ -679,15 +670,6 @@ struct XFTPServerSummaryView: View { Section("Server address") { Text(summary.xftpServer) .textSelection(.enabled) - if summary.known == true { - NavigationLink { - ProtocolServersView(serverProtocol: .xftp) - .navigationTitle("Your XFTP servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - Text("Open server settings") - } - } } if let stats = summary.stats { diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift new file mode 100644 index 0000000000..eae64e4465 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift @@ -0,0 +1,115 @@ +// +// AddressCreationCard.swift +// SimpleX (iOS) +// +// Created by Diogo Cunha on 13/11/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct AddressCreationCard: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject private var chatModel: ChatModel + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false + @State private var showAddressCreationAlert = false + @State private var showAddressSheet = false + @State private var showAddressInfoSheet = false + + var body: some View { + let addressExists = chatModel.userAddress != nil + let chats = chatModel.chats.filter { chat in + !chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card + } + ZStack(alignment: .topTrailing) { + HStack(alignment: .top, spacing: 16) { + let envelopeSize = dynamicSize(userFont).profileImageSize + Image(systemName: "envelope.circle.fill") + .resizable() + .frame(width: envelopeSize, height: envelopeSize) + .foregroundColor(.accentColor) + VStack(alignment: .leading) { + Text("Your SimpleX address") + .font(.title3) + Spacer() + HStack(alignment: .center) { + Text("How to use it") + VStack { + Image(systemName: "info.circle") + .foregroundColor(theme.colors.secondary) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .trailing) { + Image(systemName: "multiply") + .foregroundColor(theme.colors.secondary) + .onTapGesture { + showAddressCreationAlert = true + } + Spacer() + Text("Create") + .foregroundColor(.accentColor) + .onTapGesture { + showAddressSheet = true + } + } + } + .onTapGesture { + showAddressInfoSheet = true + } + .padding() + .background(theme.appColors.sentMessage) + .cornerRadius(12) + .frame(height: dynamicSize(userFont).rowHeight) + .alert(isPresented: $showAddressCreationAlert) { + Alert( + title: Text("SimpleX address"), + message: Text("You can create it in user picker."), + dismissButton: .default(Text("Ok")) { + withAnimation { + addressCreationCardShown = true + } + } + ) + } + .sheet(isPresented: $showAddressSheet) { + NavigationView { + UserAddressView(autoCreate: true) + .navigationTitle("SimpleX address") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + } + .sheet(isPresented: $showAddressInfoSheet) { + NavigationView { + UserAddressLearnMore(showCreateAddressButton: true) + .navigationTitle("SimpleX address") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + } + .onChange(of: addressExists) { exists in + if exists, !addressCreationCardShown { + addressCreationCardShown = true + } + } + .onChange(of: chats.count) { size in + if size >= 3, !addressCreationCardShown { + addressCreationCardShown = true + } + } + .onAppear { + if addressExists, !addressCreationCardShown { + addressCreationCardShown = true + } + } + } +} + +#Preview { + AddressCreationCard() +} diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift new file mode 100644 index 0000000000..19d67bc62c --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -0,0 +1,436 @@ +// +// ChooseServerOperators.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 31.10.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +let conditionsURL = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md")! + +struct OnboardingButtonStyle: ButtonStyle { + @EnvironmentObject var theme: AppTheme + var isDisabled: Bool = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 17, weight: .semibold)) + .padding() + .frame(maxWidth: .infinity) + .background( + isDisabled + ? ( + theme.colors.isLight + ? .gray.opacity(0.17) + : .gray.opacity(0.27) + ) + : theme.colors.primary + ) + .foregroundColor( + isDisabled + ? ( + theme.colors.isLight + ? .gray.opacity(0.4) + : .white.opacity(0.2) + ) + : .white + ) + .cornerRadius(16) + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + } +} + +private enum ChooseServerOperatorsSheet: Identifiable { + case showInfo + case showConditions + + var id: String { + switch self { + case .showInfo: return "showInfo" + case .showConditions: return "showConditions" + } + } +} + +struct ChooseServerOperators: View { + @Environment(\.dismiss) var dismiss: DismissAction + @Environment(\.colorScheme) var colorScheme: ColorScheme + @EnvironmentObject var theme: AppTheme + var onboarding: Bool + @State private var serverOperators: [ServerOperator] = [] + @State private var selectedOperatorIds = Set() + @State private var reviewConditionsNavLinkActive = false + @State private var sheetItem: ChooseServerOperatorsSheet? = nil + @State private var notificationsModeNavLinkActive = false + @State private var justOpened = true + + var selectedOperators: [ServerOperator] { serverOperators.filter { selectedOperatorIds.contains($0.operatorId) } } + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if !onboarding { + Text("Choose operators") + .font(.largeTitle) + .bold() + } + + infoText() + + Spacer() + + ForEach(serverOperators) { srvOperator in + operatorCheckView(srvOperator) + } + Text("You can configure servers via settings.") + .font(.footnote) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 32) + + Spacer() + + let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } + let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed } + let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) + + VStack(spacing: 8) { + if !reviewForOperators.isEmpty { + reviewConditionsButton() + } else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty { + setOperatorsButton() + } else { + continueButton() + } + if onboarding { + Group { + if reviewForOperators.isEmpty { + Button("Conditions of use") { + sheetItem = .showConditions + } + } else { + Text("Conditions of use") + .foregroundColor(.clear) + } + } + .font(.callout) + .padding(.top) + } + } + .padding(.bottom) + + if !onboarding && !reviewForOperators.isEmpty { + VStack(spacing: 8) { + reviewLaterButton() + ( + Text("Conditions will be accepted for enabled operators after 30 days.") + + Text(" ") + + Text("You can configure operators in Network & servers settings.") + ) + .multilineTextAlignment(.center) + .font(.footnote) + .padding(.horizontal, 32) + } + .disabled(!canReviewLater) + .padding(.bottom) + } + } + .frame(minHeight: g.size.height) + } + .onAppear { + if justOpened { + serverOperators = ChatModel.shared.conditions.serverOperators + selectedOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) + justOpened = false + } + } + .sheet(item: $sheetItem) { item in + switch item { + case .showInfo: + ChooseServerOperatorsInfoView() + case .showConditions: + UsageConditionsView( + currUserServers: Binding.constant([]), + userServers: Binding.constant([]) + ) + .modifier(ThemedBackground(grouped: true)) + } + } + } + .frame(maxHeight: .infinity) + .padding() + } + + private func infoText() -> some View { + HStack(spacing: 12) { + Image(systemName: "info.circle") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundColor(theme.colors.primary) + .onTapGesture { + sheetItem = .showInfo + } + + Text("Select network operators to use.") + } + } + + @ViewBuilder private func operatorCheckView(_ serverOperator: ServerOperator) -> some View { + let checked = selectedOperatorIds.contains(serverOperator.operatorId) + let icon = checked ? "checkmark.circle.fill" : "circle" + let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme) + HStack(spacing: 10) { + Image(serverOperator.largeLogo(colorScheme)) + .resizable() + .scaledToFit() + .frame(height: 48) + Spacer() + Image(systemName: icon) + .resizable() + .scaledToFit() + .frame(width: 26, height: 26) + .foregroundColor(iconColor) + } + .background(theme.colors.background) + .padding() + .clipShape(RoundedRectangle(cornerRadius: 18)) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + .padding(.horizontal, 2) + .onTapGesture { + if checked { + selectedOperatorIds.remove(serverOperator.operatorId) + } else { + selectedOperatorIds.insert(serverOperator.operatorId) + } + } + } + + private func reviewConditionsButton() -> some View { + ZStack { + Button { + reviewConditionsNavLinkActive = true + } label: { + Text("Review conditions") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) + + NavigationLink(isActive: $reviewConditionsNavLinkActive) { + reviewConditionsDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func setOperatorsButton() -> some View { + notificationsModeNavLinkButton { + Button { + Task { + if let enabledOperators = enabledOperators(serverOperators) { + let r = try await setServerOperators(operators: enabledOperators) + await MainActor.run { + ChatModel.shared.conditions = r + continueToNextStep() + } + } else { + await MainActor.run { + continueToNextStep() + } + } + } + } label: { + Text("Update") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) + } + } + + private func continueButton() -> some View { + notificationsModeNavLinkButton { + Button { + continueToNextStep() + } label: { + Text("Continue") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) + } + } + + private func reviewLaterButton() -> some View { + notificationsModeNavLinkButton { + Button { + continueToNextStep() + } label: { + Text("Review later") + } + .buttonStyle(.borderless) + } + } + + private func continueToNextStep() { + if onboarding { + onboardingStageDefault.set(.step4_SetNotificationsMode) + notificationsModeNavLinkActive = true + } else { + dismiss() + } + } + + func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View { + ZStack { + button() + + NavigationLink(isActive: $notificationsModeNavLinkActive) { + notificationsModeDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func notificationsModeDestinationView() -> some View { + SetNotificationsMode() + .navigationTitle("Push notifications") + .navigationBarTitleDisplayMode(.large) + .navigationBarBackButtonHidden(true) + .modifier(ThemedBackground()) + } + + private func reviewConditionsDestinationView() -> some View { + reviewConditionsView() + .navigationTitle("Conditions of use") + .navigationBarTitleDisplayMode(.large) + .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) } + .modifier(ThemedBackground(grouped: true)) + } + + @ViewBuilder private func reviewConditionsView() -> some View { + let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted } + let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } + VStack(alignment: .leading, spacing: 20) { + if !operatorsWithConditionsAccepted.isEmpty { + Text("Conditions are already accepted for following operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.") + Text("Same conditions will apply to operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.") + } else { + Text("Conditions will be accepted for operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.") + } + ConditionsTextView() + acceptConditionsButton() + .padding(.bottom) + .padding(.bottom) + } + .padding(.horizontal) + .frame(maxHeight: .infinity) + } + + private func acceptConditionsButton() -> some View { + notificationsModeNavLinkButton { + Button { + Task { + do { + let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId + let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } + let operatorIds = acceptForOperators.map { $0.operatorId } + let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds) + await MainActor.run { + ChatModel.shared.conditions = r + } + if let enabledOperators = enabledOperators(r.serverOperators) { + let r2 = try await setServerOperators(operators: enabledOperators) + await MainActor.run { + ChatModel.shared.conditions = r2 + continueToNextStep() + } + } else { + await MainActor.run { + continueToNextStep() + } + } + } catch let error { + await MainActor.run { + showAlert( + NSLocalizedString("Error accepting conditions", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } label: { + Text("Accept conditions") + } + .buttonStyle(OnboardingButtonStyle()) + } + } + + private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? { + var ops = operators + if !ops.isEmpty { + for i in 0.. some View { - HStack { + func createProfileButton() -> some View { + ZStack { Button { - hideKeyboard() - withAnimation { - m.onboardingStage = .step1_SimpleXInfo - } + createProfile() } label: { - HStack { - Image(systemName: "lessthan") - Text("About SimpleX") - } - } - - Spacer() - - Button { - createProfile(displayName, showAlert: showAlert, dismiss: dismiss) - } label: { - HStack { - Text("Create") - Image(systemName: "greaterthan") - } + Text("Create profile") } + .buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName))) .disabled(!canCreateProfile(displayName)) + + NavigationLink(isActive: $nextStepNavLinkActive) { + nextStepDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() } } private func showAlert(_ alert: UserProfileAlert) { AlertManager.shared.showAlert(userProfileAlert(alert, $displayName)) } + + private func nextStepDestinationView() -> some View { + ChooseServerOperators(onboarding: true) + .navigationTitle("Choose operators") + .navigationBarTitleDisplayMode(.large) + .navigationBarBackButtonHidden(true) + .modifier(ThemedBackground()) + } + + private func createProfile() { + hideKeyboard() + let profile = Profile( + displayName: displayName.trimmingCharacters(in: .whitespaces), + fullName: "" + ) + let m = ChatModel.shared + do { + AppChatState.shared.set(.active) + m.currentUser = try apiCreateActiveUser(profile) + try startChat(onboarding: true) + onboardingStageDefault.set(.step3_ChooseServerOperators) + nextStepNavLinkActive = true + } catch let error { + showCreateProfileAlert(showAlert: showAlert, error) + } + } } -private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) { - hideKeyboard() - let profile = Profile( - displayName: displayName.trimmingCharacters(in: .whitespaces), - fullName: "" - ) +private func showCreateProfileAlert( + showAlert: (UserProfileAlert) -> Void, + _ error: Error +) { let m = ChatModel.shared - do { - AppChatState.shared.set(.active) - m.currentUser = try apiCreateActiveUser(profile) - // .isEmpty check is redundant here, but it makes it clearer what is going on - if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) { - try startChat() - withAnimation { - onboardingStageDefault.set(.step3_CreateSimpleXAddress) - m.onboardingStage = .step3_CreateSimpleXAddress - } + switch error as? ChatResponse { + case .chatCmdError(_, .errorStore(.duplicateName)), + .chatCmdError(_, .error(.userExists)): + if m.currentUser == nil { + AlertManager.shared.showAlert(duplicateUserAlert) } else { - onboardingStageDefault.set(.onboardingComplete) - m.onboardingStage = .onboardingComplete - dismiss() - m.users = try listUsers() - try getUserChatData() + showAlert(.duplicateUserError) } - } catch let error { - switch error as? ChatResponse { - case .chatCmdError(_, .errorStore(.duplicateName)), - .chatCmdError(_, .error(.userExists)): - if m.currentUser == nil { - AlertManager.shared.showAlert(duplicateUserAlert) - } else { - showAlert(.duplicateUserError) - } - case .chatCmdError(_, .error(.invalidDisplayName)): - if m.currentUser == nil { - AlertManager.shared.showAlert(invalidDisplayNameAlert) - } else { - showAlert(.invalidDisplayNameError) - } - default: - let err: LocalizedStringKey = "Error: \(responseError(error))" - if m.currentUser == nil { - AlertManager.shared.showAlert(creatUserErrorAlert(err)) - } else { - showAlert(.createUserError(error: err)) - } + case .chatCmdError(_, .error(.invalidDisplayName)): + if m.currentUser == nil { + AlertManager.shared.showAlert(invalidDisplayNameAlert) + } else { + showAlert(.invalidDisplayNameError) + } + default: + let err: LocalizedStringKey = "Error: \(responseError(error))" + if m.currentUser == nil { + AlertManager.shared.showAlert(creatUserErrorAlert(err)) + } else { + showAlert(.createUserError(error: err)) } - logger.error("Failed to create user or start chat: \(responseError(error))") } + logger.error("Failed to create user or start chat: \(responseError(error))") } private func canCreateProfile(_ displayName: String) -> Bool { diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift index c1975765d2..9a0ee4ddeb 100644 --- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -9,13 +9,16 @@ import SwiftUI struct HowItWorks: View { + @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var m: ChatModel var onboarding: Bool + @Binding var createProfileNavLinkActive: Bool var body: some View { VStack(alignment: .leading) { Text("How SimpleX works") .font(.largeTitle) + .bold() .padding(.vertical) ScrollView { VStack(alignment: .leading) { @@ -37,8 +40,8 @@ struct HowItWorks: View { Spacer() if onboarding { - OnboardingActionButton() - .padding(.bottom, 8) + createFirstProfileButton() + .padding(.bottom) } } .lineLimit(10) @@ -46,10 +49,23 @@ struct HowItWorks: View { .frame(maxHeight: .infinity, alignment: .top) .modifier(ThemedBackground()) } + + private func createFirstProfileButton() -> some View { + Button { + dismiss() + createProfileNavLinkActive = true + } label: { + Text("Create your profile") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + } } struct HowItWorks_Previews: PreviewProvider { static var previews: some View { - HowItWorks(onboarding: true) + HowItWorks( + onboarding: true, + createProfileNavLinkActive: Binding.constant(false) + ) } } diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index 438491b5f1..d004e0306f 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -12,20 +12,39 @@ struct OnboardingView: View { var onboarding: OnboardingStage var body: some View { - switch onboarding { - case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) - case .step2_CreateProfile: CreateFirstProfile() - case .step3_CreateSimpleXAddress: CreateSimpleXAddress() - case .step4_SetNotificationsMode: SetNotificationsMode() - case .onboardingComplete: EmptyView() + NavigationView { + switch onboarding { + case .step1_SimpleXInfo: + SimpleXInfo(onboarding: true) + .modifier(ThemedBackground()) + case .step2_CreateProfile: // deprecated + CreateFirstProfile() + .modifier(ThemedBackground()) + case .step3_CreateSimpleXAddress: // deprecated + CreateSimpleXAddress() + case .step3_ChooseServerOperators: + ChooseServerOperators(onboarding: true) + .navigationTitle("Choose operators") + .navigationBarTitleDisplayMode(.large) + .navigationBarBackButtonHidden(true) + .modifier(ThemedBackground()) + case .step4_SetNotificationsMode: + SetNotificationsMode() + .navigationTitle("Push notifications") + .navigationBarTitleDisplayMode(.large) + .navigationBarBackButtonHidden(true) + .modifier(ThemedBackground()) + case .onboardingComplete: EmptyView() + } } } } enum OnboardingStage: String, Identifiable { case step1_SimpleXInfo - case step2_CreateProfile - case step3_CreateSimpleXAddress + case step2_CreateProfile // deprecated + case step3_CreateSimpleXAddress // deprecated + case step3_ChooseServerOperators case step4_SetNotificationsMode case onboardingComplete diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift index 7681a42a77..91a755459a 100644 --- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift +++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift @@ -15,41 +15,39 @@ struct SetNotificationsMode: View { @State private var showAlert: NotificationAlert? var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text("Push notifications") - .font(.largeTitle) - .bold() - .frame(maxWidth: .infinity) - - Text("Send notifications:") - ForEach(NotificationsMode.values) { mode in - NtfModeSelector(mode: mode, selection: $notificationMode) - } - - Spacer() - - Button { - if let token = m.deviceToken { - setNotificationsMode(token, notificationMode) - } else { - AlertManager.shared.showAlertMsg(title: "No device token!") + GeometryReader { g in + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Send notifications:") + ForEach(NotificationsMode.values) { mode in + NtfModeSelector(mode: mode, selection: $notificationMode) } - onboardingStageDefault.set(.onboardingComplete) - m.onboardingStage = .onboardingComplete - } label: { - if case .off = notificationMode { - Text("Use chat") - } else { - Text("Enable notifications") + + Spacer() + + Button { + if let token = m.deviceToken { + setNotificationsMode(token, notificationMode) + } else { + AlertManager.shared.showAlertMsg(title: "No device token!") + } + onboardingStageDefault.set(.onboardingComplete) + m.onboardingStage = .onboardingComplete + } label: { + if case .off = notificationMode { + Text("Use chat") + } else { + Text("Enable notifications") + } } + .buttonStyle(OnboardingButtonStyle()) + .padding(.bottom) } - .font(.title) - .frame(maxWidth: .infinity) + .padding() + .frame(minHeight: g.size.height) } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } + .frame(maxHeight: .infinity) } private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) { diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index ee5a618e68..2d90fb2fb2 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -13,12 +13,13 @@ struct SimpleXInfo: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme: ColorScheme @State private var showHowItWorks = false + @State private var createProfileNavLinkActive = false var onboarding: Bool var body: some View { GeometryReader { g in ScrollView { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 20) { Image(colorScheme == .light ? "logo" : "logo-light") .resizable() .aspectRatio(contentMode: .fit) @@ -42,9 +43,9 @@ struct SimpleXInfo: View { } Spacer() + if onboarding { - OnboardingActionButton() - Spacer() + createFirstProfileButton() Button { m.migrationState = .pasteOrScanLink @@ -52,7 +53,6 @@ struct SimpleXInfo: View { Label("Migrate from another device", systemImage: "tray.and.arrow.down") .font(.subheadline) } - .padding(.bottom, 8) .frame(maxWidth: .infinity) } @@ -62,9 +62,8 @@ struct SimpleXInfo: View { Label("How it works", systemImage: "info.circle") .font(.subheadline) } - .padding(.bottom, 8) .frame(maxWidth: .infinity) - + .padding(.bottom) } .frame(minHeight: g.size.height) } @@ -83,7 +82,10 @@ struct SimpleXInfo: View { } } .sheet(isPresented: $showHowItWorks) { - HowItWorks(onboarding: onboarding) + HowItWorks( + onboarding: onboarding, + createProfileNavLinkActive: $createProfileNavLinkActive + ) } } .frame(maxHeight: .infinity) @@ -108,49 +110,31 @@ struct SimpleXInfo: View { .padding(.bottom, 20) .padding(.trailing, 6) } -} -struct OnboardingActionButton: View { - @EnvironmentObject var m: ChatModel - @Environment(\.colorScheme) var colorScheme + private func createFirstProfileButton() -> some View { + ZStack { + Button { + createProfileNavLinkActive = true + } label: { + Text("Create your profile") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) - var body: some View { - if m.currentUser == nil { - actionButton("Create your profile", onboarding: .step2_CreateProfile) - } else { - actionButton("Make a private connection", onboarding: .onboardingComplete) + NavigationLink(isActive: $createProfileNavLinkActive) { + createProfileDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() } } - private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View { - Button { - withAnimation { - onboardingStageDefault.set(onboarding) - m.onboardingStage = onboarding - } - } label: { - HStack { - Text(label).font(.title2) - Image(systemName: "greaterthan") - } - } - .frame(maxWidth: .infinity) - .padding(.bottom) - } - - private func actionButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View { - Button { - withAnimation { - action() - } - } label: { - HStack { - Text(label).font(.title2) - Image(systemName: "greaterthan") - } - } - .frame(maxWidth: .infinity) - .padding(.bottom) + private func createProfileDestinationView() -> some View { + CreateFirstProfile() + .navigationTitle("Create your profile") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground()) } } diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 2ae4aa8c2b..c1c2cb8383 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -7,190 +7,209 @@ // import SwiftUI +import SimpleXChat private struct VersionDescription { var version: String var post: URL? - var features: [FeatureDescription] + var features: [Feature] } -private struct FeatureDescription { - var icon: String? - var title: LocalizedStringKey - var description: LocalizedStringKey? +private enum Feature: Identifiable { + case feature(Description) + case view(FeatureView) + + var id: LocalizedStringKey { + switch self { + case let .feature(d): d.title + case let .view(v): v.title + } + } +} + +private struct Description { + let icon: String? + let title: LocalizedStringKey + let description: LocalizedStringKey? var subfeatures: [(icon: String, description: LocalizedStringKey)] = [] } +private struct FeatureView { + let icon: String? + let title: LocalizedStringKey + let view: () -> any View +} + private let versionDescriptions: [VersionDescription] = [ VersionDescription( version: "v4.2", post: URL(string: "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"), features: [ - FeatureDescription( + .feature(Description( icon: "checkmark.shield", title: "Security assessment", description: "SimpleX Chat security was audited by Trail of Bits." - ), - FeatureDescription( + )), + .feature(Description( icon: "person.2", title: "Group links", description: "Admins can create the links to join groups." - ), - FeatureDescription( + )), + .feature(Description( icon: "checkmark", title: "Auto-accept contact requests", description: "With optional welcome message." - ), + )), ] ), VersionDescription( version: "v4.3", post: URL(string: "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html"), features: [ - FeatureDescription( + .feature(Description( icon: "mic", title: "Voice messages", description: "Max 30 seconds, received instantly." - ), - FeatureDescription( + )), + .feature(Description( icon: "trash.slash", title: "Irreversible message deletion", description: "Your contacts can allow full message deletion." - ), - FeatureDescription( + )), + .feature(Description( icon: "externaldrive.connected.to.line.below", title: "Improved server configuration", description: "Add servers by scanning QR codes." - ), - FeatureDescription( + )), + .feature(Description( icon: "eye.slash", title: "Improved privacy and security", description: "Hide app screen in the recent apps." - ), + )), ] ), VersionDescription( version: "v4.4", post: URL(string: "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html"), features: [ - FeatureDescription( + .feature(Description( icon: "stopwatch", title: "Disappearing messages", description: "Sent messages will be deleted after set time." - ), - FeatureDescription( + )), + .feature(Description( icon: "ellipsis.circle", title: "Live messages", description: "Recipients see updates as you type them." - ), - FeatureDescription( + )), + .feature(Description( icon: "checkmark.shield", title: "Verify connection security", description: "Compare security codes with your contacts." - ), - FeatureDescription( + )), + .feature(Description( icon: "camera", title: "GIFs and stickers", description: "Send them from gallery or custom keyboards." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "French interface", description: "Thanks to the users – contribute via Weblate!" - ) + )), ] ), VersionDescription( version: "v4.5", post: URL(string: "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html"), features: [ - FeatureDescription( + .feature(Description( icon: "person.crop.rectangle.stack", title: "Multiple chat profiles", description: "Different names, avatars and transport isolation." - ), - FeatureDescription( + )), + .feature(Description( icon: "rectangle.and.pencil.and.ellipsis", title: "Message draft", description: "Preserve the last message draft, with attachments." - ), - FeatureDescription( + )), + .feature(Description( icon: "network.badge.shield.half.filled", title: "Transport isolation", description: "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." - ), - FeatureDescription( + )), + .feature(Description( icon: "lock.doc", title: "Private filenames", description: "To protect timezone, image/voice files use UTC." - ), - FeatureDescription( + )), + .feature(Description( icon: "battery.25", title: "Reduced battery usage", description: "More improvements are coming soon!" - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Italian interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ) + )), ] ), VersionDescription( version: "v4.6", post: URL(string: "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html"), features: [ - FeatureDescription( + .feature(Description( icon: "lock", title: "Hidden chat profiles", description: "Protect your chat profiles with a password!" - ), - FeatureDescription( + )), + .feature(Description( icon: "phone.arrow.up.right", title: "Audio and video calls", description: "Fully re-implemented - work in background!" - ), - FeatureDescription( + )), + .feature(Description( icon: "flag", title: "Group moderation", description: "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" - ), - FeatureDescription( + )), + .feature(Description( icon: "plus.message", title: "Group welcome message", description: "Set the message shown to new members!" - ), - FeatureDescription( + )), + .feature(Description( icon: "battery.50", title: "Further reduced battery usage", description: "More improvements are coming soon!" - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Chinese and Spanish interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), VersionDescription( version: "v5.0", post: URL(string: "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html"), features: [ - FeatureDescription( + .feature(Description( icon: "arrow.up.doc", title: "Videos and files up to 1gb", description: "Fast and no wait until the sender is online!" - ), - FeatureDescription( + )), + .feature(Description( icon: "lock", title: "App passcode", description: "Set it instead of system authentication." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Polish interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), // Also @@ -200,240 +219,240 @@ private let versionDescriptions: [VersionDescription] = [ version: "v5.1", post: URL(string: "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html"), features: [ - FeatureDescription( + .feature(Description( icon: "face.smiling", title: "Message reactions", description: "Finally, we have them! 🚀" - ), - FeatureDescription( + )), + .feature(Description( icon: "arrow.up.message", title: "Better messages", description: "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." - ), - FeatureDescription( + )), + .feature(Description( icon: "lock", title: "Self-destruct passcode", description: "All data is erased when it is entered." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Japanese interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), VersionDescription( version: "v5.2", post: URL(string: "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"), features: [ - FeatureDescription( + .feature(Description( icon: "checkmark", title: "Message delivery receipts!", description: "The second tick we missed! ✅" - ), - FeatureDescription( + )), + .feature(Description( icon: "star", title: "Find chats faster", description: "Filter unread and favorite chats." - ), - FeatureDescription( + )), + .feature(Description( icon: "exclamationmark.arrow.triangle.2.circlepath", title: "Keep your connections", description: "Fix encryption after restoring backups." - ), - FeatureDescription( + )), + .feature(Description( icon: "stopwatch", title: "Make one message disappear", description: "Even when disabled in the conversation." - ), - FeatureDescription( + )), + .feature(Description( icon: "gift", title: "A few more things", description: "- more stable message delivery.\n- a bit better groups.\n- and more!" - ), + )), ] ), VersionDescription( version: "v5.3", post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"), features: [ - FeatureDescription( + .feature(Description( icon: "desktopcomputer", title: "New desktop app!", description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" - ), - FeatureDescription( + )), + .feature(Description( icon: "lock", title: "Encrypt stored files & media", description: "App encrypts new local files (except videos)." - ), - FeatureDescription( + )), + .feature(Description( icon: "magnifyingglass", title: "Discover and join groups", description: "- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." - ), - FeatureDescription( + )), + .feature(Description( icon: "theatermasks", title: "Simplified incognito mode", description: "Toggle incognito when connecting." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "\(4) new interface languages", description: "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), VersionDescription( version: "v5.4", post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"), features: [ - FeatureDescription( + .feature(Description( icon: "desktopcomputer", title: "Link mobile and desktop apps! 🔗", description: "Via secure quantum resistant protocol." - ), - FeatureDescription( + )), + .feature(Description( icon: "person.2", title: "Better groups", description: "Faster joining and more reliable messages." - ), - FeatureDescription( + )), + .feature(Description( icon: "theatermasks", title: "Incognito groups", description: "Create a group using a random profile." - ), - FeatureDescription( + )), + .feature(Description( icon: "hand.raised", title: "Block group members", description: "To hide unwanted messages." - ), - FeatureDescription( + )), + .feature(Description( icon: "gift", title: "A few more things", description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" - ), + )), ] ), VersionDescription( version: "v5.5", post: URL(string: "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html"), features: [ - FeatureDescription( + .feature(Description( icon: "folder", title: "Private notes", description: "With encrypted files and media." - ), - FeatureDescription( + )), + .feature(Description( icon: "link", title: "Paste link to connect!", description: "Search bar accepts invitation links." - ), - FeatureDescription( + )), + .feature(Description( icon: "bubble.left.and.bubble.right", title: "Join group conversations", description: "Recent history and improved [directory bot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)." - ), - FeatureDescription( + )), + .feature(Description( icon: "battery.50", title: "Improved message delivery", description: "With reduced battery usage." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Turkish interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), VersionDescription( version: "v5.6", post: URL(string: "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html"), features: [ - FeatureDescription( + .feature(Description( icon: "key", title: "Quantum resistant encryption", description: "Enable in direct chats (BETA)!" - ), - FeatureDescription( + )), + .feature(Description( icon: "tray.and.arrow.up", title: "App data migration", description: "Migrate to another device via QR code." - ), - FeatureDescription( + )), + .feature(Description( icon: "phone", title: "Picture-in-picture calls", description: "Use the app while in the call." - ), - FeatureDescription( + )), + .feature(Description( icon: "hand.raised", title: "Safer groups", description: "Admins can block a member for all." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Hungarian interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), VersionDescription( version: "v5.7", post: URL(string: "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html"), features: [ - FeatureDescription( + .feature(Description( icon: "key", title: "Quantum resistant encryption", description: "Will be enabled in direct chats!" - ), - FeatureDescription( + )), + .feature(Description( icon: "arrowshape.turn.up.forward", title: "Forward and save messages", description: "Message source remains private." - ), - FeatureDescription( + )), + .feature(Description( icon: "music.note", title: "In-call sounds", description: "When connecting audio and video calls." - ), - FeatureDescription( + )), + .feature(Description( icon: "person.crop.square", title: "Shape profile images", description: "Square, circle, or anything in between." - ), - FeatureDescription( + )), + .feature(Description( icon: "antenna.radiowaves.left.and.right", title: "Network management", description: "More reliable network connection." - ) + )), ] ), VersionDescription( version: "v5.8", post: URL(string: "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html"), features: [ - FeatureDescription( + .feature(Description( icon: "arrow.forward", title: "Private message routing 🚀", description: "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." - ), - FeatureDescription( + )), + .feature(Description( icon: "network.badge.shield.half.filled", title: "Safely receive files", description: "Confirm files from unknown servers." - ), - FeatureDescription( + )), + .feature(Description( icon: "battery.50", title: "Improved message delivery", description: "With reduced battery usage." - ) + )), ] ), VersionDescription( version: "v6.0", post: URL(string: "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html"), features: [ - FeatureDescription( + .feature(Description( icon: nil, title: "New chat experience 🎉", description: nil, @@ -444,8 +463,8 @@ private let versionDescriptions: [VersionDescription] = [ ("platter.filled.bottom.and.arrow.down.iphone", "Use the app with one hand."), ("paintpalette", "Color chats with the new themes."), ] - ), - FeatureDescription( + )), + .feature(Description( icon: nil, title: "New media options", description: nil, @@ -454,39 +473,39 @@ private let versionDescriptions: [VersionDescription] = [ ("play.circle", "Play from the chat list."), ("circle.filled.pattern.diagonalline.rectangle", "Blur for better privacy.") ] - ), - FeatureDescription( + )), + .feature(Description( icon: "arrow.forward", title: "Private message routing 🚀", description: "It protects your IP address and connections." - ), - FeatureDescription( + )), + .feature(Description( icon: "network", title: "Better networking", description: "Connection and servers status." - ) + )), ] ), VersionDescription( version: "v6.1", post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"), features: [ - FeatureDescription( + .feature(Description( icon: "checkmark.shield", title: "Better security ✅", description: "SimpleX protocols reviewed by Trail of Bits." - ), - FeatureDescription( + )), + .feature(Description( icon: "video", title: "Better calls", description: "Switch audio and video during the call." - ), - FeatureDescription( + )), + .feature(Description( icon: "bolt", title: "Better notifications", description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!" - ), - FeatureDescription( + )), + .feature(Description( icon: nil, title: "Better user experience", description: nil, @@ -497,9 +516,25 @@ private let versionDescriptions: [VersionDescription] = [ ("arrowshape.turn.up.right", "Forward up to 20 messages at once."), ("flag", "Delete or moderate up to 200 messages.") ] - ), + )), ] ), + VersionDescription( + version: "v6.2 (beta.1)", + post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"), + features: [ + .view(FeatureView( + icon: nil, + title: "Network decentralization", + view: { NewOperatorsView() } + )), + .feature(Description( + icon: "text.quote", + title: "Improved chat navigation", + description: "- Open chat on the first unread message.\n- Jump to quoted messages." + )), + ] + ) ] private let lastVersion = versionDescriptions.last!.version @@ -514,14 +549,68 @@ func shouldShowWhatsNew() -> Bool { return v != lastVersion } +fileprivate struct NewOperatorsView: View { + @State private var showOperatorsSheet = false + + var body: some View { + VStack(alignment: .leading) { + Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo) + .resizable() + .scaledToFit() + .frame(height: 48) + Text("The second preset operator in the app!") + .multilineTextAlignment(.leading) + .lineLimit(10) + HStack { + Button("Enable Flux") { + showOperatorsSheet = true + } + Text("for better metadata privacy.") + } + } + .sheet(isPresented: $showOperatorsSheet) { + NavigationView { + ChooseServerOperators(onboarding: false) + .modifier(ThemedBackground()) + } + } + } +} + +private enum WhatsNewViewSheet: Identifiable { + case showConditions + + var id: String { + switch self { + case .showConditions: return "showConditions" + } + } +} + struct WhatsNewView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme @State var currentVersion = versionDescriptions.count - 1 @State var currentVersionNav = versionDescriptions.count - 1 var viaSettings = false + var updatedConditions: Bool + @State private var sheetItem: WhatsNewViewSheet? = nil var body: some View { + whatsNewView() + .sheet(item: $sheetItem) { item in + switch item { + case .showConditions: + UsageConditionsView( + currUserServers: Binding.constant([]), + userServers: Binding.constant([]) + ) + .modifier(ThemedBackground(grouped: true)) + } + } + } + + private func whatsNewView() -> some View { VStack { TabView(selection: $currentVersion) { ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in @@ -532,9 +621,11 @@ struct WhatsNewView: View { .foregroundColor(theme.colors.secondary) .frame(maxWidth: .infinity) .padding(.vertical) - ForEach(v.features, id: \.title) { f in - featureDescription(f) - .padding(.bottom, 8) + ForEach(v.features) { f in + switch f { + case let .feature(d): featureDescription(d).padding(.bottom, 8) + case let .view(v): AnyView(v.view()).padding(.bottom, 8) + } } if let post = v.post { Link(destination: post) { @@ -544,13 +635,20 @@ struct WhatsNewView: View { } } } + if updatedConditions { + Button("View updated conditions") { + sheetItem = .showConditions + } + } if !viaSettings { Spacer() + Button("Ok") { dismiss() } .font(.title3) .frame(maxWidth: .infinity, alignment: .center) + Spacer() } } @@ -568,20 +666,24 @@ struct WhatsNewView: View { currentVersionNav = currentVersion } } - - private func featureDescription(_ f: FeatureDescription) -> some View { - VStack(alignment: .leading, spacing: 4) { - if let icon = f.icon { - HStack(alignment: .center, spacing: 4) { - Image(systemName: icon) - .symbolRenderingMode(.monochrome) - .foregroundColor(theme.colors.secondary) - .frame(minWidth: 30, alignment: .center) - Text(f.title).font(.title3).bold() - } - } else { - Text(f.title).font(.title3).bold() + + @ViewBuilder private func featureHeader(_ icon: String?, _ title: LocalizedStringKey) -> some View { + if let icon { + HStack(alignment: .center, spacing: 4) { + Image(systemName: icon) + .symbolRenderingMode(.monochrome) + .foregroundColor(theme.colors.secondary) + .frame(minWidth: 30, alignment: .center) + Text(title).font(.title3).bold() } + } else { + Text(title).font(.title3).bold() + } + } + + private func featureDescription(_ f: Description) -> some View { + VStack(alignment: .leading, spacing: 4) { + featureHeader(f.icon, f.title) if let d = f.description { Text(d) .multilineTextAlignment(.leading) @@ -636,6 +738,6 @@ struct WhatsNewView: View { struct NewFeaturesView_Previews: PreviewProvider { static var previews: some View { - WhatsNewView() + WhatsNewView(updatedConditions: false) } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift deleted file mode 100644 index 155a3956be..0000000000 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// NetworkServersView.swift -// SimpleX (iOS) -// -// Created by Evgeny on 02/08/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI -import SimpleXChat - -private enum NetworkAlert: Identifiable { - case error(err: String) - - var id: String { - switch self { - case let .error(err): return "error \(err)" - } - } -} - -struct NetworkAndServers: View { - @EnvironmentObject var m: ChatModel - @EnvironmentObject var theme: AppTheme - - var body: some View { - VStack { - List { - Section { - NavigationLink { - ProtocolServersView(serverProtocol: .smp) - .navigationTitle("Your SMP servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - Text("Message servers") - } - - NavigationLink { - ProtocolServersView(serverProtocol: .xftp) - .navigationTitle("Your XFTP servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - Text("Media & file servers") - } - - NavigationLink { - AdvancedNetworkSettings() - .navigationTitle("Advanced settings") - .modifier(ThemedBackground(grouped: true)) - } label: { - Text("Advanced network settings") - } - } header: { - Text("Messages & files") - .foregroundColor(theme.colors.secondary) - } - - Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) { - NavigationLink { - RTCServers() - .navigationTitle("Your ICE servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - Text("WebRTC ICE servers") - } - } - - Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) { - HStack { - Text(m.networkInfo.networkType.text) - Spacer() - Image(systemName: "circle.fill").foregroundColor(m.networkInfo.online ? .green : .red) - } - } - } - } - } -} - -struct NetworkServersView_Previews: PreviewProvider { - static var previews: some View { - NetworkAndServers() - } -} diff --git a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift similarity index 100% rename from apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift rename to apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift new file mode 100644 index 0000000000..8b6421b502 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -0,0 +1,434 @@ +// +// NetworkServersView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 02/08/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private enum NetworkAlert: Identifiable { + case error(err: String) + + var id: String { + switch self { + case let .error(err): return "error \(err)" + } + } +} + +private enum NetworkAndServersSheet: Identifiable { + case showConditions + + var id: String { + switch self { + case .showConditions: return "showConditions" + } + } +} + +struct NetworkAndServers: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var m: ChatModel + @Environment(\.colorScheme) var colorScheme: ColorScheme + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var ss: SaveableSettings + @State private var sheetItem: NetworkAndServersSheet? = nil + @State private var justOpened = true + @State private var showSaveDialog = false + + var body: some View { + VStack { + List { + let conditionsAction = m.conditions.conditionsAction + let anyOperatorEnabled = ss.servers.userServers.contains(where: { $0.operator?.enabled ?? false }) + Section { + ForEach(ss.servers.userServers.enumerated().map { $0 }, id: \.element.id) { idx, userOperatorServers in + if let serverOperator = userOperatorServers.operator { + serverOperatorView(idx, serverOperator) + } else { + EmptyView() + } + } + + if let conditionsAction = conditionsAction, anyOperatorEnabled { + conditionsButton(conditionsAction) + } + } header: { + Text("Preset servers") + .foregroundColor(theme.colors.secondary) + } footer: { + switch conditionsAction { + case let .review(_, deadline, _): + if let deadline = deadline, anyOperatorEnabled { + Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).") + .foregroundColor(theme.colors.secondary) + } + default: + EmptyView() + } + } + + Section { + if let idx = ss.servers.userServers.firstIndex(where: { $0.operator == nil }) { + NavigationLink { + YourServersView( + userServers: $ss.servers.userServers, + serverErrors: $ss.servers.serverErrors, + operatorIndex: idx + ) + .navigationTitle("Your servers") + .modifier(ThemedBackground(grouped: true)) + } label: { + HStack { + Text("Your servers") + + if ss.servers.userServers[idx] != ss.servers.currUserServers[idx] { + Spacer() + unsavedChangesIndicator() + } + } + } + } + + NavigationLink { + AdvancedNetworkSettings() + .navigationTitle("Advanced settings") + .modifier(ThemedBackground(grouped: true)) + } label: { + Text("Advanced network settings") + } + } header: { + Text("Messages & files") + .foregroundColor(theme.colors.secondary) + } + + Section { + Button("Save servers", action: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) }) + .disabled(!serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors)) + } footer: { + if let errStr = globalServersError(ss.servers.serverErrors) { + ServersErrorView(errStr: errStr) + } else if !ss.servers.serverErrors.isEmpty { + ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error")) + } + } + + Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) { + NavigationLink { + RTCServers() + .navigationTitle("Your ICE servers") + .modifier(ThemedBackground(grouped: true)) + } label: { + Text("WebRTC ICE servers") + } + } + + Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) { + HStack { + Text(m.networkInfo.networkType.text) + Spacer() + Image(systemName: "circle.fill").foregroundColor(m.networkInfo.online ? .green : .red) + } + } + } + } + .task { + // this condition is needed to prevent re-setting the servers when exiting single server view + if justOpened { + do { + ss.servers.currUserServers = try await getUserServers() + ss.servers.userServers = ss.servers.currUserServers + ss.servers.serverErrors = [] + } catch let error { + await MainActor.run { + showAlert( + NSLocalizedString("Error loading servers", comment: "alert title"), + message: responseError(error) + ) + } + } + justOpened = false + } + } + .modifier(BackButton(disabled: Binding.constant(false)) { + if serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors) { + showSaveDialog = true + } else { + dismiss() + } + }) + .confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) { + Button("Save") { + saveServers($ss.servers.currUserServers, $ss.servers.userServers) + dismiss() + } + Button("Exit without saving") { dismiss() } + } + .sheet(item: $sheetItem) { item in + switch item { + case .showConditions: + UsageConditionsView( + currUserServers: $ss.servers.currUserServers, + userServers: $ss.servers.userServers + ) + .modifier(ThemedBackground(grouped: true)) + } + } + } + + private func serverOperatorView(_ operatorIndex: Int, _ serverOperator: ServerOperator) -> some View { + NavigationLink() { + OperatorView( + currUserServers: $ss.servers.currUserServers, + userServers: $ss.servers.userServers, + serverErrors: $ss.servers.serverErrors, + operatorIndex: operatorIndex, + useOperator: serverOperator.enabled + ) + .navigationBarTitle("\(serverOperator.tradeName) servers") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + HStack { + Image(serverOperator.logo(colorScheme)) + .resizable() + .scaledToFit() + .grayscale(serverOperator.enabled ? 0.0 : 1.0) + .frame(width: 24, height: 24) + Text(serverOperator.tradeName) + .foregroundColor(serverOperator.enabled ? theme.colors.onBackground : theme.colors.secondary) + + if ss.servers.userServers[operatorIndex] != ss.servers.currUserServers[operatorIndex] { + Spacer() + unsavedChangesIndicator() + } + } + } + } + + private func unsavedChangesIndicator() -> some View { + Image(systemName: "pencil") + .foregroundColor(theme.colors.secondary) + .symbolRenderingMode(.monochrome) + .frame(maxWidth: 24, maxHeight: 24, alignment: .center) + } + + private func conditionsButton(_ conditionsAction: UsageConditionsAction) -> some View { + Button { + sheetItem = .showConditions + } label: { + switch conditionsAction { + case .review: + Text("Review conditions") + case .accepted: + Text("Accepted conditions") + } + } + } +} + +struct UsageConditionsView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @Binding var currUserServers: [UserOperatorServers] + @Binding var userServers: [UserOperatorServers] + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + HStack { + Text("Conditions of use").font(.largeTitle).bold() + Spacer() + conditionsLinkButton() + } + .padding(.top) + .padding(.top) + + switch ChatModel.shared.conditions.conditionsAction { + + case .none: + ConditionsTextView() + .padding(.bottom) + .padding(.bottom) + + case let .review(operators, deadline, _): + Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.") + ConditionsTextView() + VStack(spacing: 8) { + acceptConditionsButton(operators.map { $0.operatorId }) + if let deadline = deadline { + Text("Conditions will be automatically accepted for enabled operators on: \(conditionsTimestamp(deadline)).") + .foregroundColor(theme.colors.secondary) + .font(.footnote) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 32) + } + } + .padding(.bottom) + .padding(.bottom) + + case let .accepted(operators): + Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.") + ConditionsTextView() + .padding(.bottom) + .padding(.bottom) + } + } + .padding(.horizontal) + .frame(maxHeight: .infinity) + } + + private func acceptConditionsButton(_ operatorIds: [Int64]) -> some View { + Button { + acceptForOperators(operatorIds) + } label: { + Text("Accept conditions") + } + .buttonStyle(OnboardingButtonStyle()) + } + + func acceptForOperators(_ operatorIds: [Int64]) { + Task { + do { + let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId + let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds) + await MainActor.run { + ChatModel.shared.conditions = r + updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators) + updateOperatorsConditionsAcceptance($userServers, r.serverOperators) + dismiss() + } + } catch let error { + await MainActor.run { + showAlert( + NSLocalizedString("Error accepting conditions", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } +} + +func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) { + let userServersToValidate = userServers.wrappedValue + Task { + do { + let errs = try await validateServers(userServers: userServersToValidate) + await MainActor.run { + serverErrors.wrappedValue = errs + } + } catch let error { + logger.error("validateServers error: \(responseError(error))") + } + } +} + +func serversCanBeSaved( + _ currUserServers: [UserOperatorServers], + _ userServers: [UserOperatorServers], + _ serverErrors: [UserServersError] +) -> Bool { + return userServers != currUserServers && serverErrors.isEmpty +} + +struct ServersErrorView: View { + @EnvironmentObject var theme: AppTheme + var errStr: String + + var body: some View { + HStack { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + Text(errStr) + .foregroundColor(theme.colors.secondary) + } + } +} + +func globalServersError(_ serverErrors: [UserServersError]) -> String? { + for err in serverErrors { + if let errStr = err.globalError { + return errStr + } + } + return nil +} + +func globalSMPServersError(_ serverErrors: [UserServersError]) -> String? { + for err in serverErrors { + if let errStr = err.globalSMPError { + return errStr + } + } + return nil +} + +func globalXFTPServersError(_ serverErrors: [UserServersError]) -> String? { + for err in serverErrors { + if let errStr = err.globalXFTPError { + return errStr + } + } + return nil +} + +func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set { + let duplicateHostsList = serverErrors.compactMap { err in + if case let .duplicateServer(_, _, duplicateHost) = err { + return duplicateHost + } else { + return nil + } + } + return Set(duplicateHostsList) +} + +func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServers: Binding<[UserOperatorServers]>) { + let userServersToSave = userServers.wrappedValue + Task { + do { + try await setUserServers(userServers: userServersToSave) + // Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers) + do { + let updatedServers = try await getUserServers() + let updatedOperators = try await getServerOperators() + await MainActor.run { + ChatModel.shared.conditions = updatedOperators + currUserServers.wrappedValue = updatedServers + userServers.wrappedValue = updatedServers + } + } catch let error { + logger.error("saveServers getUserServers error: \(responseError(error))") + await MainActor.run { + currUserServers.wrappedValue = userServersToSave + } + } + } catch let error { + logger.error("saveServers setUserServers error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error saving servers", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + +func updateOperatorsConditionsAcceptance(_ usvs: Binding<[UserOperatorServers]>, _ updatedOperators: [ServerOperator]) { + for i in 0.. some View { + VStack { + let serverAddress = parseServerAddress(serverToEdit.server) + let valid = serverAddress?.valid == true + List { + Section { + TextEditor(text: $serverToEdit.server) + .multilineTextAlignment(.leading) + .autocorrectionDisabled(true) + .autocapitalization(.none) + .allowsTightening(true) + .lineLimit(10) + .frame(height: 144) + .padding(-6) + } header: { + HStack { + Text("Your server address") + .foregroundColor(theme.colors.secondary) + if !valid { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } + } + useServerSection(valid) + if valid { + Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) { + MutableQRCode(uri: $serverToEdit.server) + .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) + } + } + } + } + } + + private func useServerSection(_ valid: Bool) -> some View { + Section(header: Text("Use server").foregroundColor(theme.colors.secondary)) { + HStack { + Button("Test server") { + testing = true + serverToEdit.tested = nil + Task { + if let f = await testServerConnection(server: $serverToEdit) { + showTestFailure = true + testFailure = f + } + await MainActor.run { testing = false } + } + } + .disabled(!valid || testing) + Spacer() + showTestStatus(server: serverToEdit) + } + Toggle("Use for new connections", isOn: $serverToEdit.enabled) + } + } +} + +func serverProtocolAndOperator(_ server: UserServer, _ userServers: [UserOperatorServers]) -> (ServerProtocol, ServerOperator?)? { + if let serverAddress = parseServerAddress(server.server) { + let serverProtocol = serverAddress.serverProtocol + let hostnames = serverAddress.hostnames + let matchingOperator = userServers.compactMap { $0.operator }.first { op in + op.serverDomains.contains { domain in + hostnames.contains { hostname in + hostname.hasSuffix(domain) + } + } + } + return (serverProtocol, matchingOperator) + } else { + return nil + } +} + +func addServer( + _ server: UserServer, + _ userServers: Binding<[UserOperatorServers]>, + _ serverErrors: Binding<[UserServersError]>, + _ dismiss: DismissAction +) { + if let (serverProtocol, matchingOperator) = serverProtocolAndOperator(server, userServers.wrappedValue) { + if let i = userServers.wrappedValue.firstIndex(where: { $0.operator?.operatorId == matchingOperator?.operatorId }) { + switch serverProtocol { + case .smp: userServers[i].wrappedValue.smpServers.append(server) + case .xftp: userServers[i].wrappedValue.xftpServers.append(server) + } + validateServers_(userServers, serverErrors) + dismiss() + if let op = matchingOperator { + showAlert( + NSLocalizedString("Operator server", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Server added to operator %@.", comment: "alert message"), op.tradeName) + ) + } + } else { // Shouldn't happen + dismiss() + showAlert(NSLocalizedString("Error adding server", comment: "alert title")) + } + } else { + dismiss() + if server.server.trimmingCharacters(in: .whitespaces) != "" { + showAlert( + NSLocalizedString("Invalid server address!", comment: "alert title"), + message: NSLocalizedString("Check server address and try again.", comment: "alert title") + ) + } + } +} + +#Preview { + NewServerView( + userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), + serverErrors: Binding.constant([]) + ) +} diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift new file mode 100644 index 0000000000..83152a001f --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -0,0 +1,585 @@ +// +// OperatorView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.10.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct OperatorView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @Environment(\.colorScheme) var colorScheme: ColorScheme + @EnvironmentObject var theme: AppTheme + @Environment(\.editMode) private var editMode + @Binding var currUserServers: [UserOperatorServers] + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + var operatorIndex: Int + @State var useOperator: Bool + @State private var useOperatorToggleReset: Bool = false + @State private var showConditionsSheet: Bool = false + @State private var selectedServer: String? = nil + @State private var testing = false + + var body: some View { + operatorView() + .opacity(testing ? 0.4 : 1) + .overlay { + if testing { + ProgressView() + .scaleEffect(2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .allowsHitTesting(!testing) + } + + @ViewBuilder private func operatorView() -> some View { + let duplicateHosts = findDuplicateHosts(serverErrors) + VStack { + List { + Section { + infoViewLink() + useOperatorToggle() + } header: { + Text("Operator") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } else { + switch (userServers[operatorIndex].operator_.conditionsAcceptance) { + case let .accepted(acceptedAt): + if let acceptedAt = acceptedAt { + Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).") + .foregroundColor(theme.colors.secondary) + } + case let .required(deadline): + if userServers[operatorIndex].operator_.enabled, let deadline = deadline { + Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).") + .foregroundColor(theme.colors.secondary) + } + } + } + } + + if userServers[operatorIndex].operator_.enabled { + if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { + Section { + Toggle("To receive", isOn: $userServers[operatorIndex].operator_.smpRoles.storage) + .onChange(of: userServers[operatorIndex].operator_.smpRoles.storage) { _ in + validateServers_($userServers, $serverErrors) + } + Toggle("For private routing", isOn: $userServers[operatorIndex].operator_.smpRoles.proxy) + .onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in + validateServers_($userServers, $serverErrors) + } + } header: { + Text("Use for messages") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalSMPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } + } + } + + // Preset servers can't be deleted + if !userServers[operatorIndex].smpServers.filter({ $0.preset }).isEmpty { + Section { + ForEach($userServers[operatorIndex].smpServers) { srv in + if srv.wrappedValue.preset { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .smp, + backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } + } + } header: { + Text("Message servers") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalSMPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } else { + Text("The servers for new connections of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.") + .foregroundColor(theme.colors.secondary) + .lineLimit(10) + } + } + } + + if !userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty { + Section { + ForEach($userServers[operatorIndex].smpServers) { srv in + if !srv.wrappedValue.preset && !srv.wrappedValue.deleted { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .smp, + backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } + } + .onDelete { indexSet in + deleteSMPServer($userServers, operatorIndex, indexSet) + validateServers_($userServers, $serverErrors) + } + } header: { + Text("Added message servers") + .foregroundColor(theme.colors.secondary) + } + } + + if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty { + Section { + Toggle("To send", isOn: $userServers[operatorIndex].operator_.xftpRoles.storage) + .onChange(of: userServers[operatorIndex].operator_.xftpRoles.storage) { _ in + validateServers_($userServers, $serverErrors) + } + } header: { + Text("Use for files") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalXFTPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } + } + } + + // Preset servers can't be deleted + if !userServers[operatorIndex].xftpServers.filter({ $0.preset }).isEmpty { + Section { + ForEach($userServers[operatorIndex].xftpServers) { srv in + if srv.wrappedValue.preset { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .xftp, + backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } + } + } header: { + Text("Media & file servers") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalXFTPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } else { + Text("The servers for new files of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.") + .foregroundColor(theme.colors.secondary) + .lineLimit(10) + } + } + } + + if !userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty { + Section { + ForEach($userServers[operatorIndex].xftpServers) { srv in + if !srv.wrappedValue.preset && !srv.wrappedValue.deleted { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .xftp, + backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } + } + .onDelete { indexSet in + deleteXFTPServer($userServers, operatorIndex, indexSet) + validateServers_($userServers, $serverErrors) + } + } header: { + Text("Added media & file servers") + .foregroundColor(theme.colors.secondary) + } + } + + Section { + TestServersButton( + smpServers: $userServers[operatorIndex].smpServers, + xftpServers: $userServers[operatorIndex].xftpServers, + testing: $testing + ) + } + } + } + } + .toolbar { + if ( + !userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty || + !userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty + ) { + EditButton() + } + } + .sheet(isPresented: $showConditionsSheet, onDismiss: onUseToggleSheetDismissed) { + SingleOperatorUsageConditionsView( + currUserServers: $currUserServers, + userServers: $userServers, + serverErrors: $serverErrors, + operatorIndex: operatorIndex + ) + .modifier(ThemedBackground(grouped: true)) + } + } + + private func infoViewLink() -> some View { + NavigationLink() { + OperatorInfoView(serverOperator: userServers[operatorIndex].operator_) + .navigationBarTitle("Network operator") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Image(userServers[operatorIndex].operator_.largeLogo(colorScheme)) + .resizable() + .scaledToFit() + .grayscale(userServers[operatorIndex].operator_.enabled ? 0.0 : 1.0) + .frame(height: 40) + } + } + + private func useOperatorToggle() -> some View { + Toggle("Use servers", isOn: $useOperator) + .onChange(of: useOperator) { useOperatorToggle in + if useOperatorToggleReset { + useOperatorToggleReset = false + } else if useOperatorToggle { + switch userServers[operatorIndex].operator_.conditionsAcceptance { + case .accepted: + userServers[operatorIndex].operator_.enabled = true + validateServers_($userServers, $serverErrors) + case let .required(deadline): + if deadline == nil { + showConditionsSheet = true + } else { + userServers[operatorIndex].operator_.enabled = true + validateServers_($userServers, $serverErrors) + } + } + } else { + userServers[operatorIndex].operator_.enabled = false + validateServers_($userServers, $serverErrors) + } + } + } + + private func onUseToggleSheetDismissed() { + if useOperator && !userServers[operatorIndex].operator_.conditionsAcceptance.usageAllowed { + useOperatorToggleReset = true + useOperator = false + } + } +} + +func conditionsTimestamp(_ date: Date) -> String { + let localDateFormatter = DateFormatter() + localDateFormatter.dateStyle = .medium + localDateFormatter.timeStyle = .none + return localDateFormatter.string(from: date) +} + +struct OperatorInfoView: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + var serverOperator: ServerOperator + + var body: some View { + VStack { + List { + Section { + VStack(alignment: .leading) { + Image(serverOperator.largeLogo(colorScheme)) + .resizable() + .scaledToFit() + .frame(height: 48) + if let legalName = serverOperator.legalName { + Text(legalName) + } + } + } + Section { + VStack(alignment: .leading, spacing: 12) { + ForEach(serverOperator.info.description, id: \.self) { d in + Text(d) + } + } + } + Section { + Link("\(serverOperator.info.website)", destination: URL(string: serverOperator.info.website)!) + } + } + } + } +} + +struct ConditionsTextView: View { + @State private var conditionsData: (UsageConditions, String?, UsageConditions?)? + @State private var failedToLoad: Bool = false + + let defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md" + + var body: some View { + viewBody() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + do { + conditionsData = try await getUsageConditions() + } catch let error { + logger.error("ConditionsTextView getUsageConditions error: \(responseError(error))") + failedToLoad = true + } + } + } + + // TODO Markdown & diff rendering + @ViewBuilder private func viewBody() -> some View { + if let (usageConditions, conditionsText, acceptedConditions) = conditionsData { + if let conditionsText = conditionsText { + ScrollView { + Text(conditionsText.trimmingCharacters(in: .whitespacesAndNewlines)) + .padding() + } + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + } else { + let conditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/\(usageConditions.conditionsCommit)/PRIVACY.md" + conditionsLinkView(conditionsLink) + } + } else if failedToLoad { + conditionsLinkView(defaultConditionsLink) + } else { + ProgressView() + .scaleEffect(2) + } + } + + private func conditionsLinkView(_ conditionsLink: String) -> some View { + VStack(alignment: .leading, spacing: 20) { + Text("Current conditions text couldn't be loaded, you can review conditions via this link:") + Link(destination: URL(string: conditionsLink)!) { + Text(conditionsLink) + .multilineTextAlignment(.leading) + } + } + } +} + +struct SingleOperatorUsageConditionsView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @Binding var currUserServers: [UserOperatorServers] + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + var operatorIndex: Int + @State private var usageConditionsNavLinkActive: Bool = false + + var body: some View { + viewBody() + } + + @ViewBuilder private func viewBody() -> some View { + let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted } + if case .accepted = userServers[operatorIndex].operator_.conditionsAcceptance { + + // In current UI implementation this branch doesn't get shown - as conditions can't be opened from inside operator once accepted + VStack(alignment: .leading, spacing: 20) { + Group { + viewHeader() + ConditionsTextView() + .padding(.bottom) + .padding(.bottom) + } + .padding(.horizontal) + } + .frame(maxHeight: .infinity) + + } else if !operatorsWithConditionsAccepted.isEmpty { + + NavigationView { + VStack(alignment: .leading, spacing: 20) { + Group { + viewHeader() + Text("Conditions are already accepted for following operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.") + Text("Same conditions will apply to operator **\(userServers[operatorIndex].operator_.legalName_)**.") + conditionsAppliedToOtherOperatorsText() + usageConditionsNavLinkButton() + + Spacer() + + acceptConditionsButton() + .padding(.bottom) + .padding(.bottom) + } + .padding(.horizontal) + } + .frame(maxHeight: .infinity) + } + + } else { + + VStack(alignment: .leading, spacing: 20) { + Group { + viewHeader() + Text("To use the servers of **\(userServers[operatorIndex].operator_.legalName_)**, accept conditions of use.") + conditionsAppliedToOtherOperatorsText() + ConditionsTextView() + acceptConditionsButton() + .padding(.bottom) + .padding(.bottom) + } + .padding(.horizontal) + } + .frame(maxHeight: .infinity) + + } + } + + private func viewHeader() -> some View { + HStack { + Text("Use \(userServers[operatorIndex].operator_.tradeName)").font(.largeTitle).bold() + Spacer() + conditionsLinkButton() + } + .padding(.top) + .padding(.top) + } + + @ViewBuilder private func conditionsAppliedToOtherOperatorsText() -> some View { + let otherOperatorsToApply = ChatModel.shared.conditions.serverOperators.filter { + $0.enabled && + !$0.conditionsAcceptance.conditionsAccepted && + $0.operatorId != userServers[operatorIndex].operator_.operatorId + } + if !otherOperatorsToApply.isEmpty { + Text("These conditions will also apply for: **\(otherOperatorsToApply.map { $0.legalName_ }.joined(separator: ", "))**.") + } + } + + @ViewBuilder private func acceptConditionsButton() -> some View { + let operatorIds = ChatModel.shared.conditions.serverOperators + .filter { + $0.operatorId == userServers[operatorIndex].operator_.operatorId || // Opened operator + ($0.enabled && !$0.conditionsAcceptance.conditionsAccepted) // Other enabled operators with conditions not accepted + } + .map { $0.operatorId } + Button { + acceptForOperators(operatorIds, operatorIndex) + } label: { + Text("Accept conditions") + } + .buttonStyle(OnboardingButtonStyle()) + } + + func acceptForOperators(_ operatorIds: [Int64], _ operatorIndexToEnable: Int) { + Task { + do { + let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId + let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds) + await MainActor.run { + ChatModel.shared.conditions = r + updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators) + updateOperatorsConditionsAcceptance($userServers, r.serverOperators) + userServers[operatorIndexToEnable].operator?.enabled = true + validateServers_($userServers, $serverErrors) + dismiss() + } + } catch let error { + await MainActor.run { + showAlert( + NSLocalizedString("Error accepting conditions", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } + + private func usageConditionsNavLinkButton() -> some View { + ZStack { + Button { + usageConditionsNavLinkActive = true + } label: { + Text("View conditions") + } + + NavigationLink(isActive: $usageConditionsNavLinkActive) { + usageConditionsDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func usageConditionsDestinationView() -> some View { + ConditionsTextView() + .padding() + .padding(.bottom) + .navigationTitle("Conditions of use") + .navigationBarTitleDisplayMode(.large) + .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) } + .modifier(ThemedBackground(grouped: true)) + } +} + +func conditionsLinkButton() -> some View { + let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit + let mdUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/\(commit)/PRIVACY.md") ?? conditionsURL + return Menu { + Link(destination: mdUrl) { + Label("Open conditions", systemImage: "doc") + } + if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") { + Link(destination: commitUrl) { + Label("Open changes", systemImage: "ellipsis") + } + } + } label: { + Image(systemName: "arrow.up.right.circle") + .resizable() + .scaledToFit() + .frame(width: 20) + .padding(2) + .contentShape(Circle()) + } +} + +#Preview { + OperatorView( + currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), + userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), + serverErrors: Binding.constant([]), + operatorIndex: 1, + useOperator: ServerOperator.sampleData1.enabled + ) +} diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift similarity index 70% rename from apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift rename to apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift index da29dfac29..13d01874ed 100644 --- a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift @@ -12,15 +12,15 @@ import SimpleXChat struct ProtocolServerView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme - let serverProtocol: ServerProtocol - @Binding var server: ServerCfg - @State var serverToEdit: ServerCfg + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @Binding var server: UserServer + @State var serverToEdit: UserServer + var backLabel: LocalizedStringKey @State private var showTestFailure = false @State private var testing = false @State private var testFailure: ProtocolTestFailure? - var proto: String { serverProtocol.rawValue.uppercased() } - var body: some View { ZStack { if server.preset { @@ -32,9 +32,33 @@ struct ProtocolServerView: View { ProgressView().scaleEffect(2) } } - .modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) { - server = serverToEdit - dismiss() + .modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) { + if let (serverToEditProtocol, serverToEditOperator) = serverProtocolAndOperator(serverToEdit, userServers), + let (serverProtocol, serverOperator) = serverProtocolAndOperator(server, userServers) { + if serverToEditProtocol != serverProtocol { + dismiss() + showAlert( + NSLocalizedString("Error updating server", comment: "alert title"), + message: NSLocalizedString("Server protocol changed.", comment: "alert title") + ) + } else if serverToEditOperator != serverOperator { + dismiss() + showAlert( + NSLocalizedString("Error updating server", comment: "alert title"), + message: NSLocalizedString("Server operator changed.", comment: "alert title") + ) + } else { + server = serverToEdit + validateServers_($userServers, $serverErrors) + dismiss() + } + } else { + dismiss() + showAlert( + NSLocalizedString("Invalid server address!", comment: "alert title"), + message: NSLocalizedString("Check server address and try again.", comment: "alert title") + ) + } }) .alert(isPresented: $showTestFailure) { Alert( @@ -62,7 +86,7 @@ struct ProtocolServerView: View { private func customServer() -> some View { VStack { let serverAddress = parseServerAddress(serverToEdit.server) - let valid = serverAddress?.valid == true && serverAddress?.serverProtocol == serverProtocol + let valid = serverAddress?.valid == true List { Section { TextEditor(text: $serverToEdit.server) @@ -112,10 +136,7 @@ struct ProtocolServerView: View { Spacer() showTestStatus(server: serverToEdit) } - let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset Toggle("Use for new connections", isOn: $serverToEdit.enabled) - .disabled(useForNewDisabled) - .foregroundColor(useForNewDisabled ? theme.colors.secondary : theme.colors.onBackground) } } } @@ -142,7 +163,7 @@ struct BackButton: ViewModifier { } } -@ViewBuilder func showTestStatus(server: ServerCfg) -> some View { +@ViewBuilder func showTestStatus(server: UserServer) -> some View { switch server.tested { case .some(true): Image(systemName: "checkmark") @@ -155,7 +176,7 @@ struct BackButton: ViewModifier { } } -func testServerConnection(server: Binding) async -> ProtocolTestFailure? { +func testServerConnection(server: Binding) async -> ProtocolTestFailure? { do { let r = try await testProtoServer(server: server.wrappedValue.server) switch r { @@ -178,9 +199,11 @@ func testServerConnection(server: Binding) async -> ProtocolTestFailu struct ProtocolServerView_Previews: PreviewProvider { static var previews: some View { ProtocolServerView( - serverProtocol: .smp, - server: Binding.constant(ServerCfg.sampleData.custom), - serverToEdit: ServerCfg.sampleData.custom + userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), + serverErrors: Binding.constant([]), + server: Binding.constant(UserServer.sampleData.custom), + serverToEdit: UserServer.sampleData.custom, + backLabel: "Your SMP servers" ) } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift new file mode 100644 index 0000000000..ed3c5c773c --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -0,0 +1,359 @@ +// +// ProtocolServersView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 15/11/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")! + +struct YourServersView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject private var m: ChatModel + @EnvironmentObject var theme: AppTheme + @Environment(\.editMode) private var editMode + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + var operatorIndex: Int + @State private var selectedServer: String? = nil + @State private var showAddServer = false + @State private var newServerNavLinkActive = false + @State private var showScanProtoServer = false + @State private var testing = false + + var body: some View { + yourServersView() + .opacity(testing ? 0.4 : 1) + .overlay { + if testing { + ProgressView() + .scaleEffect(2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .allowsHitTesting(!testing) + } + + @ViewBuilder private func yourServersView() -> some View { + let duplicateHosts = findDuplicateHosts(serverErrors) + List { + if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { + Section { + ForEach($userServers[operatorIndex].smpServers) { srv in + if !srv.wrappedValue.deleted { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .smp, + backLabel: "Your servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } + } + .onDelete { indexSet in + deleteSMPServer($userServers, operatorIndex, indexSet) + validateServers_($userServers, $serverErrors) + } + } header: { + Text("Message servers") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalSMPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } else { + Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.") + .foregroundColor(theme.colors.secondary) + .lineLimit(10) + } + } + } + + if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty { + Section { + ForEach($userServers[operatorIndex].xftpServers) { srv in + if !srv.wrappedValue.deleted { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .xftp, + backLabel: "Your servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } + } + .onDelete { indexSet in + deleteXFTPServer($userServers, operatorIndex, indexSet) + validateServers_($userServers, $serverErrors) + } + } header: { + Text("Media & file servers") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalXFTPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } else { + Text("The servers for new files of your current chat profile **\(m.currentUser?.displayName ?? "")**.") + .foregroundColor(theme.colors.secondary) + .lineLimit(10) + } + } + } + + Section { + ZStack { + Button("Add server") { + showAddServer = true + } + + NavigationLink(isActive: $newServerNavLinkActive) { + newServerDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } footer: { + if let errStr = globalServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } + } + + Section { + TestServersButton( + smpServers: $userServers[operatorIndex].smpServers, + xftpServers: $userServers[operatorIndex].xftpServers, + testing: $testing + ) + howToButton() + } + } + .toolbar { + if ( + !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty || + !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty + ) { + EditButton() + } + } + .confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) { + Button("Enter server manually") { newServerNavLinkActive = true } + Button("Scan server QR code") { showScanProtoServer = true } + } + .sheet(isPresented: $showScanProtoServer) { + ScanProtocolServer( + userServers: $userServers, + serverErrors: $serverErrors + ) + .modifier(ThemedBackground(grouped: true)) + } + } + + private func newServerDestinationView() -> some View { + NewServerView( + userServers: $userServers, + serverErrors: $serverErrors + ) + .navigationTitle("New server") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + + func howToButton() -> some View { + Button { + DispatchQueue.main.async { + UIApplication.shared.open(howToUrl) + } + } label: { + HStack { + Text("How to use your servers") + Image(systemName: "arrow.up.right.circle") + } + } + } +} + +struct ProtocolServerViewLink: View { + @EnvironmentObject var theme: AppTheme + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + var duplicateHosts: Set + @Binding var server: UserServer + var serverProtocol: ServerProtocol + var backLabel: LocalizedStringKey + @Binding var selectedServer: String? + + var body: some View { + let proto = serverProtocol.rawValue.uppercased() + + NavigationLink(tag: server.id, selection: $selectedServer) { + ProtocolServerView( + userServers: $userServers, + serverErrors: $serverErrors, + server: $server, + serverToEdit: server, + backLabel: backLabel + ) + .navigationBarTitle("\(proto) server") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + let address = parseServerAddress(server.server) + HStack { + Group { + if let address = address { + if !address.valid || address.serverProtocol != serverProtocol { + invalidServer() + } else if address.hostnames.contains(where: duplicateHosts.contains) { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } else if !server.enabled { + Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary) + } else { + showTestStatus(server: server) + } + } else { + invalidServer() + } + } + .frame(width: 16, alignment: .center) + .padding(.trailing, 4) + + let v = Text(address?.hostnames.first ?? server.server).lineLimit(1) + if server.enabled { + v + } else { + v.foregroundColor(theme.colors.secondary) + } + } + } + } + + private func invalidServer() -> some View { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } +} + +func deleteSMPServer( + _ userServers: Binding<[UserOperatorServers]>, + _ operatorServersIndex: Int, + _ serverIndexSet: IndexSet +) { + if let idx = serverIndexSet.first { + let server = userServers[operatorServersIndex].wrappedValue.smpServers[idx] + if server.serverId == nil { + userServers[operatorServersIndex].wrappedValue.smpServers.remove(at: idx) + } else { + var updatedServer = server + updatedServer.deleted = true + userServers[operatorServersIndex].wrappedValue.smpServers[idx] = updatedServer + } + } +} + +func deleteXFTPServer( + _ userServers: Binding<[UserOperatorServers]>, + _ operatorServersIndex: Int, + _ serverIndexSet: IndexSet +) { + if let idx = serverIndexSet.first { + let server = userServers[operatorServersIndex].wrappedValue.xftpServers[idx] + if server.serverId == nil { + userServers[operatorServersIndex].wrappedValue.xftpServers.remove(at: idx) + } else { + var updatedServer = server + updatedServer.deleted = true + userServers[operatorServersIndex].wrappedValue.xftpServers[idx] = updatedServer + } + } +} + +struct TestServersButton: View { + @Binding var smpServers: [UserServer] + @Binding var xftpServers: [UserServer] + @Binding var testing: Bool + + var body: some View { + Button("Test servers", action: testServers) + .disabled(testing || allServersDisabled) + } + + private var allServersDisabled: Bool { + smpServers.allSatisfy { !$0.enabled } && xftpServers.allSatisfy { !$0.enabled } + } + + private func testServers() { + resetTestStatus() + testing = true + Task { + let fs = await runServersTest() + await MainActor.run { + testing = false + if !fs.isEmpty { + let msg = fs.map { (srv, f) in + "\(srv): \(f.localizedDescription)" + }.joined(separator: "\n") + showAlert( + NSLocalizedString("Tests failed!", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Some servers failed the test:\n%@", comment: "alert message"), msg) + ) + } + } + } + } + + private func resetTestStatus() { + for i in 0.. [String: ProtocolTestFailure] { + var fs: [String: ProtocolTestFailure] = [:] + for i in 0..) { switch resp { case let .success(r): - if parseServerAddress(r.string) != nil { - servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false)) - dismiss() - } else { - showAddressError = true - } + var server: UserServer = .empty + server.server = r.string + addServer(server, $userServers, $serverErrors, dismiss) case let .failure(e): logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)") dismiss() @@ -54,6 +45,9 @@ struct ScanProtocolServer: View { struct ScanProtocolServer_Previews: PreviewProvider { static var previews: some View { - ScanProtocolServer(servers: Binding.constant([])) + ScanProtocolServer( + userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), + serverErrors: Binding.constant([]) + ) } } diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift deleted file mode 100644 index 0fb37d5c49..0000000000 --- a/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift +++ /dev/null @@ -1,359 +0,0 @@ -// -// ProtocolServersView.swift -// SimpleX (iOS) -// -// Created by Evgeny on 15/11/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI -import SimpleXChat - -private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")! - -struct ProtocolServersView: View { - @Environment(\.dismiss) var dismiss: DismissAction - @EnvironmentObject private var m: ChatModel - @EnvironmentObject var theme: AppTheme - @Environment(\.editMode) private var editMode - let serverProtocol: ServerProtocol - @State private var currServers: [ServerCfg] = [] - @State private var presetServers: [ServerCfg] = [] - @State private var configuredServers: [ServerCfg] = [] - @State private var otherServers: [ServerCfg] = [] - @State private var selectedServer: String? = nil - @State private var showAddServer = false - @State private var showScanProtoServer = false - @State private var justOpened = true - @State private var testing = false - @State private var alert: ServerAlert? = nil - @State private var showSaveDialog = false - - var proto: String { serverProtocol.rawValue.uppercased() } - - var body: some View { - ZStack { - protocolServersView() - if testing { - ProgressView().scaleEffect(2) - } - } - } - - enum ServerAlert: Identifiable { - case testsFailed(failures: [String: ProtocolTestFailure]) - case error(title: LocalizedStringKey, error: LocalizedStringKey = "") - - var id: String { - switch self { - case .testsFailed: return "testsFailed" - case let .error(title, _): return "error \(title)" - } - } - } - - private func protocolServersView() -> some View { - List { - if !configuredServers.isEmpty { - Section { - ForEach($configuredServers) { srv in - protocolServerView(srv) - } - .onMove { indexSet, offset in - configuredServers.move(fromOffsets: indexSet, toOffset: offset) - } - .onDelete { indexSet in - configuredServers.remove(atOffsets: indexSet) - } - } header: { - Text("Configured \(proto) servers") - .foregroundColor(theme.colors.secondary) - } footer: { - Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.") - .foregroundColor(theme.colors.secondary) - .lineLimit(10) - } - } - - if !otherServers.isEmpty { - Section { - ForEach($otherServers) { srv in - protocolServerView(srv) - } - .onMove { indexSet, offset in - otherServers.move(fromOffsets: indexSet, toOffset: offset) - } - .onDelete { indexSet in - otherServers.remove(atOffsets: indexSet) - } - } header: { - Text("Other \(proto) servers") - .foregroundColor(theme.colors.secondary) - } - } - - Section { - Button("Add server") { - showAddServer = true - } - } - - Section { - Button("Reset") { partitionServers(currServers) } - .disabled(Set(allServers) == Set(currServers) || testing) - Button("Test servers", action: testServers) - .disabled(testing || allServersDisabled) - Button("Save servers", action: saveServers) - .disabled(saveDisabled) - howToButton() - } - } - .toolbar { EditButton() } - .confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) { - Button("Enter server manually") { - otherServers.append(ServerCfg.empty) - selectedServer = allServers.last?.id - } - Button("Scan server QR code") { showScanProtoServer = true } - Button("Add preset servers", action: addAllPresets) - .disabled(hasAllPresets()) - } - .sheet(isPresented: $showScanProtoServer) { - ScanProtocolServer(servers: $otherServers) - .modifier(ThemedBackground(grouped: true)) - } - .modifier(BackButton(disabled: Binding.constant(false)) { - if saveDisabled { - dismiss() - justOpened = false - } else { - showSaveDialog = true - } - }) - .confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) { - Button("Save") { - saveServers() - dismiss() - justOpened = false - } - Button("Exit without saving") { dismiss() } - } - .alert(item: $alert) { a in - switch a { - case let .testsFailed(fs): - let msg = fs.map { (srv, f) in - "\(srv): \(f.localizedDescription)" - }.joined(separator: "\n") - return Alert( - title: Text("Tests failed!"), - message: Text("Some servers failed the test:\n" + msg) - ) - case .error: - return Alert( - title: Text("Error") - ) - } - } - .onAppear { - // this condition is needed to prevent re-setting the servers when exiting single server view - if justOpened { - do { - let r = try getUserProtoServers(serverProtocol) - currServers = r.protoServers - presetServers = r.presetServers - partitionServers(currServers) - } catch let error { - alert = .error( - title: "Error loading \(proto) servers", - error: "Error: \(responseError(error))" - ) - } - justOpened = false - } else { - partitionServers(allServers) - } - } - } - - private func partitionServers(_ servers: [ServerCfg]) { - configuredServers = servers.filter { $0.preset || $0.enabled } - otherServers = servers.filter { !($0.preset || $0.enabled) } - } - - private var allServers: [ServerCfg] { - configuredServers + otherServers - } - - private var saveDisabled: Bool { - allServers.isEmpty || - Set(allServers) == Set(currServers) || - testing || - !allServers.allSatisfy { srv in - if let address = parseServerAddress(srv.server) { - return uniqueAddress(srv, address) - } - return false - } || - allServersDisabled - } - - private var allServersDisabled: Bool { - allServers.allSatisfy { !$0.enabled } - } - - private func protocolServerView(_ server: Binding) -> some View { - let srv = server.wrappedValue - return NavigationLink(tag: srv.id, selection: $selectedServer) { - ProtocolServerView( - serverProtocol: serverProtocol, - server: server, - serverToEdit: srv - ) - .navigationBarTitle(srv.preset ? "Preset server" : "Your server") - .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) - } label: { - let address = parseServerAddress(srv.server) - HStack { - Group { - if let address = address { - if !address.valid || address.serverProtocol != serverProtocol { - invalidServer() - } else if !uniqueAddress(srv, address) { - Image(systemName: "exclamationmark.circle").foregroundColor(.red) - } else if !srv.enabled { - Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary) - } else { - showTestStatus(server: srv) - } - } else { - invalidServer() - } - } - .frame(width: 16, alignment: .center) - .padding(.trailing, 4) - - let v = Text(address?.hostnames.first ?? srv.server).lineLimit(1) - if srv.enabled { - v - } else { - v.foregroundColor(theme.colors.secondary) - } - } - } - } - - func howToButton() -> some View { - Button { - DispatchQueue.main.async { - UIApplication.shared.open(howToUrl) - } - } label: { - HStack { - Text("How to use your servers") - Image(systemName: "arrow.up.right.circle") - } - } - } - - private func invalidServer() -> some View { - Image(systemName: "exclamationmark.circle").foregroundColor(.red) - } - - private func uniqueAddress(_ s: ServerCfg, _ address: ServerAddress) -> Bool { - allServers.allSatisfy { srv in - address.hostnames.allSatisfy { host in - srv.id == s.id || !srv.server.contains(host) - } - } - } - - private func hasAllPresets() -> Bool { - presetServers.allSatisfy { hasPreset($0) } - } - - private func addAllPresets() { - for srv in presetServers { - if !hasPreset(srv) { - configuredServers.append(srv) - } - } - } - - private func hasPreset(_ srv: ServerCfg) -> Bool { - allServers.contains(where: { $0.server == srv.server }) - } - - private func testServers() { - resetTestStatus() - testing = true - Task { - let fs = await runServersTest() - await MainActor.run { - testing = false - if !fs.isEmpty { - alert = .testsFailed(failures: fs) - } - } - } - } - - private func resetTestStatus() { - for i in 0.. [String: ProtocolTestFailure] { - var fs: [String: ProtocolTestFailure] = [:] - for i in 0.. some View { + ZStack { + Button { + createAddressLinkActive = true + } label: { + Text("Create SimpleX address") + } + .buttonStyle(OnboardingButtonStyle()) + + NavigationLink(isActive: $createAddressLinkActive) { + UserAddressView(autoCreate: true) + .navigationTitle("SimpleX address") + .navigationBarTitleDisplayMode(.large) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() } } } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 2469dc59db..cbc3e9b79e 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -15,6 +15,7 @@ struct UserAddressView: View { @EnvironmentObject private var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @State var shareViaProfile = false + @State var autoCreate = false @State private var aas = AutoAcceptState() @State private var savedAAS = AutoAcceptState() @State private var ignoreShareViaProfileChange = false @@ -67,6 +68,11 @@ struct UserAddressView: View { } } } + .onAppear { + if chatModel.userAddress == nil, autoCreate { + createAddress() + } + } } @Namespace private var bottomID @@ -212,26 +218,30 @@ struct UserAddressView: View { private func createAddressButton() -> some View { Button { - progressIndicator = true - Task { - do { - let connReqContact = try await apiCreateUserAddress() - DispatchQueue.main.async { - chatModel.userAddress = UserContactLink(connReqContact: connReqContact) - alert = .shareOnCreate - progressIndicator = false - } - } catch let error { - logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))") - let a = getErrorAlert(error, "Error creating address") - alert = .error(title: a.title, error: a.message) - await MainActor.run { progressIndicator = false } - } - } + createAddress() } label: { Label("Create SimpleX address", systemImage: "qrcode") } } + + private func createAddress() { + progressIndicator = true + Task { + do { + let connReqContact = try await apiCreateUserAddress() + DispatchQueue.main.async { + chatModel.userAddress = UserContactLink(connReqContact: connReqContact) + alert = .shareOnCreate + progressIndicator = false + } + } catch let error { + logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))") + let a = getErrorAlert(error, "Error creating address") + alert = .error(title: a.title, error: a.message) + await MainActor.run { progressIndicator = false } + } + } + } private func deleteAddressButton() -> some View { Button(role: .destructive) { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 4061ef0f9c..8dc195e17f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -144,20 +144,22 @@ 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */; }; 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; }; + 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; }; 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 */; }; + 642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642BA82C2CE50495005E9412 /* NewServerView.swift */; }; + 642BA8332CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */; }; + 642BA8342CEB3D4B005E9412 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82F2CEB3D4B005E9412 /* libffi.a */; }; + 642BA8352CEB3D4B005E9412 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8302CEB3D4B005E9412 /* libgmp.a */; }; + 642BA8362CEB3D4B005E9412 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8312CEB3D4B005E9412 /* libgmpxx.a */; }; + 642BA8372CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */; }; 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; }; - 643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B402CCBEB080083A2CF /* libgmpxx.a */; }; - 643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */; }; - 643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B422CCBEB080083A2CF /* libffi.a */; }; - 643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */; }; - 643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B442CCBEB080083A2CF /* libgmp.a */; }; + 643B3B4E2CCFD6400083A2CF /* OperatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */; }; 6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; }; 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; }; 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; }; 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; }; - 64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */; }; 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DCB29FFE3E800E3D48D /* MailView.swift */; }; 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; }; 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; }; @@ -200,7 +202,9 @@ 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; + B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; + B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */; }; CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; }; CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; }; CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; }; @@ -436,7 +440,7 @@ 5CB634AC29E46CF70066AD6B /* LocalAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthView.swift; sourceTree = ""; }; 5CB634AE29E4BB7D0066AD6B /* SetAppPasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAppPasscodeView.swift; sourceTree = ""; }; 5CB634B029E5EFEA0066AD6B /* PasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasscodeView.swift; sourceTree = ""; }; - 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; wrapsLines = 0; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListNavLink.swift; sourceTree = ""; }; 5CBD285529565CAE00EC2CF4 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; @@ -487,20 +491,22 @@ 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatMenuButton.swift; sourceTree = ""; }; 640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; + 640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = ""; }; 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 = ""; }; + 642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = ""; }; + 642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a"; sourceTree = ""; }; + 642BA82F2CEB3D4B005E9412 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 642BA8302CEB3D4B005E9412 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 642BA8312CEB3D4B005E9412 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a"; sourceTree = ""; }; 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = ""; }; - 643B3B402CCBEB080083A2CF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmpxx.a; path = Libraries/libgmpxx.a; sourceTree = ""; }; - 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a"; sourceTree = ""; }; - 643B3B422CCBEB080083A2CF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libffi.a; path = Libraries/libffi.a; sourceTree = ""; }; - 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a"; path = "Libraries/libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a"; sourceTree = ""; }; - 643B3B442CCBEB080083A2CF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmp.a; path = Libraries/libgmp.a; sourceTree = ""; }; + 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorView.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 = ""; }; 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = ""; }; 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = ""; }; - 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = ""; }; 64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = ""; }; 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = ""; }; 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = ""; }; @@ -544,7 +550,9 @@ 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; + B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = ""; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; + B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = ""; }; CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = ""; }; CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = ""; }; CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = ""; }; @@ -657,14 +665,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */, - 643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */, - 643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a in Frameworks */, - 643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a in Frameworks */, + 642BA8342CEB3D4B005E9412 /* libffi.a in Frameworks */, + 642BA8352CEB3D4B005E9412 /* libgmp.a in Frameworks */, + 642BA8372CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a in Frameworks */, + 642BA8332CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a in Frameworks */, + 642BA8362CEB3D4B005E9412 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -741,6 +749,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( + 642BA82F2CEB3D4B005E9412 /* libffi.a */, + 642BA8302CEB3D4B005E9412 /* libgmp.a */, + 642BA8312CEB3D4B005E9412 /* libgmpxx.a */, + 642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */, + 642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */, ); path = Libraries; sourceTree = ""; @@ -812,11 +825,6 @@ 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, 5C764E5C279C70B7000C6508 /* Libraries */, - 643B3B422CCBEB080083A2CF /* libffi.a */, - 643B3B442CCBEB080083A2CF /* libgmp.a */, - 643B3B402CCBEB080083A2CF /* libgmpxx.a */, - 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */, - 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */, 5CA059C2279559F40002BEB4 /* Shared */, 5CDCAD462818589900503DA2 /* SimpleX NSE */, CEE723A82C3BD3D70009AE93 /* SimpleX SE */, @@ -875,13 +883,15 @@ 5CB0BA8C282711BC00B3292C /* Onboarding */ = { isa = PBXGroup; children = ( + B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */, 5CB0BA8D2827126500B3292C /* OnboardingView.swift */, 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */, 5CB0BA992827FD8800B3292C /* HowItWorks.swift */, 5CB0BA91282713FD00B3292C /* CreateProfile.swift */, - 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */, 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */, 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */, + 640743602CD360E600158442 /* ChooseServerOperators.swift */, + B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */, ); path = Onboarding; sourceTree = ""; @@ -912,10 +922,9 @@ 5CB924DF27A8678B00ACCCDD /* UserSettings */ = { isa = PBXGroup; children = ( + 643B3B4C2CCFD34B0083A2CF /* NetworkAndServers */, 5CB924D627A8563F00ACCCDD /* SettingsView.swift */, 5CB346E62868D76D001FD2EF /* NotificationsView.swift */, - 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, - 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */, 5CADE79929211BB900072E13 /* PreferencesView.swift */, 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */, 5C05DF522840AA1D00C683F9 /* CallSettings.swift */, @@ -923,9 +932,6 @@ 5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */, 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */, - 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */, - 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */, - 5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */, 5CB2084E28DA4B4800D024EC /* RTCServers.swift */, 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */, 18415845648CA4F5A8BCA272 /* UserProfilesView.swift */, @@ -1056,6 +1062,20 @@ path = Database; sourceTree = ""; }; + 643B3B4C2CCFD34B0083A2CF /* NetworkAndServers */ = { + isa = PBXGroup; + children = ( + 5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */, + 642BA82C2CE50495005E9412 /* NewServerView.swift */, + 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */, + 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */, + 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */, + 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, + 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */, + ); + path = NetworkAndServers; + sourceTree = ""; + }; 6440CA01288AEC770062C672 /* Group */ = { isa = PBXGroup; children = ( @@ -1375,10 +1395,12 @@ 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */, 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */, 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */, + 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */, 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, + B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */, 5C65DAF929D0CC20003CEE45 /* DeveloperView.swift in Sources */, @@ -1405,12 +1427,12 @@ 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */, 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */, + B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */, 5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */, 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */, 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, - 64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */, @@ -1528,7 +1550,9 @@ 5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */, 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */, 18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */, + 642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */, 184158C131FDB829D8A117EA /* VideoPlayerView.swift in Sources */, + 643B3B4E2CCFD6400083A2CF /* OperatorView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 3c9b91fa0b..51aa9108a1 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -72,9 +72,15 @@ public enum ChatCommand { case apiGetGroupLink(groupId: Int64) case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64) case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) - case apiGetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol) - case apiSetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol, servers: [ServerCfg]) case apiTestProtoServer(userId: Int64, server: String) + case apiGetServerOperators + case apiSetServerOperators(operators: [ServerOperator]) + case apiGetUserServers(userId: Int64) + case apiSetUserServers(userId: Int64, userServers: [UserOperatorServers]) + case apiValidateServers(userId: Int64, userServers: [UserOperatorServers]) + case apiGetUsageConditions + case apiSetConditionsNotified(conditionsId: Int64) + case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64]) case apiSetChatItemTTL(userId: Int64, seconds: Int64?) case apiGetChatItemTTL(userId: Int64) case apiSetNetworkConfig(networkConfig: NetCfg) @@ -231,9 +237,15 @@ public enum ChatCommand { case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)" case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" - case let .apiGetUserProtoServers(userId, serverProtocol): return "/_servers \(userId) \(serverProtocol)" - case let .apiSetUserProtoServers(userId, serverProtocol, servers): return "/_servers \(userId) \(serverProtocol) \(protoServersStr(servers))" case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" + case .apiGetServerOperators: return "/_operators" + case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))" + case let .apiGetUserServers(userId): return "/_servers \(userId)" + case let .apiSetUserServers(userId, userServers): return "/_servers \(userId) \(encodeJSON(userServers))" + case let .apiValidateServers(userId, userServers): return "/_validate_servers \(userId) \(encodeJSON(userServers))" + case .apiGetUsageConditions: return "/_conditions" + case let .apiSetConditionsNotified(conditionsId): return "/_conditions_notified \(conditionsId)" + case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))" case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" @@ -386,9 +398,15 @@ public enum ChatCommand { case .apiGetGroupLink: return "apiGetGroupLink" case .apiCreateMemberContact: return "apiCreateMemberContact" case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" - case .apiGetUserProtoServers: return "apiGetUserProtoServers" - case .apiSetUserProtoServers: return "apiSetUserProtoServers" case .apiTestProtoServer: return "apiTestProtoServer" + case .apiGetServerOperators: return "apiGetServerOperators" + case .apiSetServerOperators: return "apiSetServerOperators" + case .apiGetUserServers: return "apiGetUserServers" + case .apiSetUserServers: return "apiSetUserServers" + case .apiValidateServers: return "apiValidateServers" + case .apiGetUsageConditions: return "apiGetUsageConditions" + case .apiSetConditionsNotified: return "apiSetConditionsNotified" + case .apiAcceptConditions: return "apiAcceptConditions" case .apiSetChatItemTTL: return "apiSetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL" case .apiSetNetworkConfig: return "apiSetNetworkConfig" @@ -475,10 +493,6 @@ public enum ChatCommand { func joinedIds(_ ids: [Int64]) -> String { ids.map { "\($0)" }.joined(separator: ",") } - - func protoServersStr(_ servers: [ServerCfg]) -> String { - encodeJSON(ProtoServersConfig(servers: servers)) - } func chatItemTTLStr(seconds: Int64?) -> String { if let seconds = seconds { @@ -548,8 +562,11 @@ public enum ChatResponse: Decodable, Error { case apiChats(user: UserRef, chats: [ChatData]) case apiChat(user: UserRef, chat: ChatData) case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) - case userProtoServers(user: UserRef, servers: UserProtoServers) case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) + case serverOperatorConditions(conditions: ServerOperatorConditions) + case userServers(user: UserRef, userServers: [UserOperatorServers]) + case userServersValidation(user: UserRef, serverErrors: [UserServersError]) + case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?) case chatItemTTL(user: UserRef, chatItemTTL: Int64?) case networkConfig(networkConfig: NetCfg) case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?) @@ -721,8 +738,11 @@ public enum ChatResponse: Decodable, Error { case .apiChats: return "apiChats" case .apiChat: return "apiChat" case .chatItemInfo: return "chatItemInfo" - case .userProtoServers: return "userProtoServers" case .serverTestResult: return "serverTestResult" + case .serverOperatorConditions: return "serverOperators" + case .userServers: return "userServers" + case .userServersValidation: return "userServersValidation" + case .usageConditions: return "usageConditions" case .chatItemTTL: return "chatItemTTL" case .networkConfig: return "networkConfig" case .contactInfo: return "contactInfo" @@ -890,8 +910,11 @@ public enum ChatResponse: Decodable, Error { case let .apiChats(u, chats): return withUser(u, String(describing: chats)) case let .apiChat(u, chat): return withUser(u, String(describing: chat)) case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") - case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))") case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") + case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" + case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") + case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))") + case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))" case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) case let .networkConfig(networkConfig): return String(describing: networkConfig) case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))") @@ -1175,86 +1198,428 @@ public struct DBEncryptionConfig: Codable { public var newKey: String } -struct SMPServersConfig: Encodable { - var smpServers: [ServerCfg] -} - public enum ServerProtocol: String, Decodable { case smp case xftp } -public struct ProtoServersConfig: Codable { - public var servers: [ServerCfg] +public enum OperatorTag: String, Codable { + case simplex = "simplex" + case flux = "flux" + case xyz = "xyz" + case demo = "demo" } -public struct UserProtoServers: Decodable { - public var serverProtocol: ServerProtocol - public var protoServers: [ServerCfg] - public var presetServers: [ServerCfg] +public struct ServerOperatorInfo: Decodable { + public var description: [String] + public var website: String + public var logo: String + public var largeLogo: String + public var logoDarkMode: String + public var largeLogoDarkMode: String } -public struct ServerCfg: Identifiable, Equatable, Codable, Hashable { +public let operatorsInfo: Dictionary = [ + .simplex: ServerOperatorInfo( + description: [ + "SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.", + "SimpleX Chat Ltd develops the communication software for SimpleX network." + ], + website: "https://simplex.chat", + logo: "decentralized", + largeLogo: "logo", + logoDarkMode: "decentralized-light", + largeLogoDarkMode: "logo-light" + ), + .flux: ServerOperatorInfo( + description: [ + "Flux is the largest decentralized cloud infrastructure, leveraging a global network of user-operated computational nodes.", + "Flux offers a powerful, scalable, and affordable platform designed to support individuals, businesses, and cutting-edge technologies like AI. With high uptime and worldwide distribution, Flux ensures reliable, accessible cloud computing for all." + ], + website: "https://runonflux.com", + logo: "flux_logo_symbol", + largeLogo: "flux_logo", + logoDarkMode: "flux_logo_symbol", + largeLogoDarkMode: "flux_logo-light" + ), + .xyz: ServerOperatorInfo( + description: ["XYZ servers"], + website: "XYZ website", + logo: "shield", + largeLogo: "logo", + logoDarkMode: "shield", + largeLogoDarkMode: "logo-light" + ), + .demo: ServerOperatorInfo( + description: ["Demo operator"], + website: "Demo website", + logo: "decentralized", + largeLogo: "logo", + logoDarkMode: "decentralized-light", + largeLogoDarkMode: "logo-light" + ) +] + +public struct UsageConditions: Decodable { + public var conditionsId: Int64 + public var conditionsCommit: String + public var notifiedAt: Date? + public var createdAt: Date + + public static var sampleData = UsageConditions( + conditionsId: 1, + conditionsCommit: "11a44dc1fd461a93079f897048b46998db55da5c", + notifiedAt: nil, + createdAt: Date.now + ) +} + +public enum UsageConditionsAction: Decodable { + case review(operators: [ServerOperator], deadline: Date?, showNotice: Bool) + case accepted(operators: [ServerOperator]) + + public var showNotice: Bool { + switch self { + case let .review(_, _, showNotice): showNotice + case .accepted: false + } + } +} + +public struct ServerOperatorConditions: Decodable { + public var serverOperators: [ServerOperator] + public var currentConditions: UsageConditions + public var conditionsAction: UsageConditionsAction? + + public static var empty = ServerOperatorConditions( + serverOperators: [], + currentConditions: UsageConditions(conditionsId: 0, conditionsCommit: "empty", notifiedAt: nil, createdAt: .now), + conditionsAction: nil + ) +} + +public enum ConditionsAcceptance: Equatable, Codable, Hashable { + case accepted(acceptedAt: Date?) + // If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator. + // No deadline indicates it's required to accept conditions for the operator to start using it. + case required(deadline: Date?) + + public var conditionsAccepted: Bool { + switch self { + case .accepted: true + case .required: false + } + } + + public var usageAllowed: Bool { + switch self { + case .accepted: true + case let .required(deadline): deadline != nil + } + } +} + +public struct ServerOperator: Identifiable, Equatable, Codable { + public var operatorId: Int64 + public var operatorTag: OperatorTag? + public var tradeName: String + public var legalName: String? + public var serverDomains: [String] + public var conditionsAcceptance: ConditionsAcceptance + public var enabled: Bool + public var smpRoles: ServerRoles + public var xftpRoles: ServerRoles + + public var id: Int64 { operatorId } + + public static func == (l: ServerOperator, r: ServerOperator) -> Bool { + l.operatorId == r.operatorId && l.operatorTag == r.operatorTag && l.tradeName == r.tradeName && l.legalName == r.legalName && + l.serverDomains == r.serverDomains && l.conditionsAcceptance == r.conditionsAcceptance && l.enabled == r.enabled && + l.smpRoles == r.smpRoles && l.xftpRoles == r.xftpRoles + } + + public var legalName_: String { + legalName ?? tradeName + } + + public var info: ServerOperatorInfo { + return if let operatorTag = operatorTag { + operatorsInfo[operatorTag] ?? ServerOperator.dummyOperatorInfo + } else { + ServerOperator.dummyOperatorInfo + } + } + + public static let dummyOperatorInfo = ServerOperatorInfo( + description: ["Default"], + website: "Default", + logo: "decentralized", + largeLogo: "logo", + logoDarkMode: "decentralized-light", + largeLogoDarkMode: "logo-light" + ) + + public func logo(_ colorScheme: ColorScheme) -> String { + colorScheme == .light ? info.logo : info.logoDarkMode + } + + public func largeLogo(_ colorScheme: ColorScheme) -> String { + colorScheme == .light ? info.largeLogo : info.largeLogoDarkMode + } + + public static var sampleData1 = ServerOperator( + operatorId: 1, + operatorTag: .simplex, + tradeName: "SimpleX Chat", + legalName: "SimpleX Chat Ltd", + serverDomains: ["simplex.im"], + conditionsAcceptance: .accepted(acceptedAt: nil), + enabled: true, + smpRoles: ServerRoles(storage: true, proxy: true), + xftpRoles: ServerRoles(storage: true, proxy: true) + ) + + public static var sampleData2 = ServerOperator( + operatorId: 2, + operatorTag: .xyz, + tradeName: "XYZ", + legalName: nil, + serverDomains: ["xyz.com"], + conditionsAcceptance: .required(deadline: nil), + enabled: false, + smpRoles: ServerRoles(storage: false, proxy: true), + xftpRoles: ServerRoles(storage: false, proxy: true) + ) + + public static var sampleData3 = ServerOperator( + operatorId: 3, + operatorTag: .demo, + tradeName: "Demo", + legalName: nil, + serverDomains: ["demo.com"], + conditionsAcceptance: .required(deadline: nil), + enabled: false, + smpRoles: ServerRoles(storage: true, proxy: false), + xftpRoles: ServerRoles(storage: true, proxy: false) + ) +} + +public struct ServerRoles: Equatable, Codable { + public var storage: Bool + public var proxy: Bool +} + +public struct UserOperatorServers: Identifiable, Equatable, Codable { + public var `operator`: ServerOperator? + public var smpServers: [UserServer] + public var xftpServers: [UserServer] + + public var id: String { + if let op = self.operator { + "\(op.operatorId)" + } else { + "nil operator" + } + } + + public var operator_: ServerOperator { + get { + self.operator ?? ServerOperator( + operatorId: 0, + operatorTag: nil, + tradeName: "", + legalName: "", + serverDomains: [], + conditionsAcceptance: .accepted(acceptedAt: nil), + enabled: false, + smpRoles: ServerRoles(storage: true, proxy: true), + xftpRoles: ServerRoles(storage: true, proxy: true) + ) + } + set { `operator` = newValue } + } + + public static var sampleData1 = UserOperatorServers( + operator: ServerOperator.sampleData1, + smpServers: [UserServer.sampleData.preset], + xftpServers: [UserServer.sampleData.xftpPreset] + ) + + public static var sampleDataNilOperator = UserOperatorServers( + operator: nil, + smpServers: [UserServer.sampleData.preset], + xftpServers: [UserServer.sampleData.xftpPreset] + ) +} + +public enum UserServersError: Decodable { + case noServers(protocol: ServerProtocol, user: UserRef?) + case storageMissing(protocol: ServerProtocol, user: UserRef?) + case proxyMissing(protocol: ServerProtocol, user: UserRef?) + case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) + + public var globalError: String? { + switch self { + case let .noServers(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + case let .storageMissing(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + case let .proxyMissing(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + default: return nil + } + } + + public var globalSMPError: String? { + switch self { + case let .noServers(.smp, user): + let text = NSLocalizedString("No message servers.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .storageMissing(.smp, user): + let text = NSLocalizedString("No servers to receive messages.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .proxyMissing(.smp, user): + let text = NSLocalizedString("No servers for private message routing.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + default: + return nil + } + } + + public var globalXFTPError: String? { + switch self { + case let .noServers(.xftp, user): + let text = NSLocalizedString("No media & file servers.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .storageMissing(.xftp, user): + let text = NSLocalizedString("No servers to send files.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .proxyMissing(.xftp, user): + let text = NSLocalizedString("No servers to receive files.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + default: + return nil + } + } + + private func userStr(_ user: UserRef) -> String { + String.localizedStringWithFormat(NSLocalizedString("For chat profile %@:", comment: "servers error"), user.localDisplayName) + } +} + +public struct UserServer: Identifiable, Equatable, Codable, Hashable { + public var serverId: Int64? public var server: String public var preset: Bool public var tested: Bool? public var enabled: Bool + public var deleted: Bool var createdAt = Date() -// public var sendEnabled: Bool // can we potentially want to prevent sending on the servers we use to receive? -// Even if we don't see the use case, it's probably better to allow it in the model -// In any case, "trusted/known" servers are out of scope of this change - public init(server: String, preset: Bool, tested: Bool?, enabled: Bool) { + public init(serverId: Int64?, server: String, preset: Bool, tested: Bool?, enabled: Bool, deleted: Bool) { + self.serverId = serverId self.server = server self.preset = preset self.tested = tested self.enabled = enabled + self.deleted = deleted } - public static func == (l: ServerCfg, r: ServerCfg) -> Bool { - l.server == r.server && l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled + public static func == (l: UserServer, r: UserServer) -> Bool { + l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested && + l.enabled == r.enabled && l.deleted == r.deleted } public var id: String { "\(server) \(createdAt)" } - public static var empty = ServerCfg(server: "", preset: false, tested: nil, enabled: false) + public static var empty = UserServer(serverId: nil, server: "", preset: false, tested: nil, enabled: false, deleted: false) public var isEmpty: Bool { server.trimmingCharacters(in: .whitespaces) == "" } public struct SampleData { - public var preset: ServerCfg - public var custom: ServerCfg - public var untested: ServerCfg + public var preset: UserServer + public var custom: UserServer + public var untested: UserServer + public var xftpPreset: UserServer } public static var sampleData = SampleData( - preset: ServerCfg( + preset: UserServer( + serverId: 1, server: "smp://abcd@smp8.simplex.im", preset: true, tested: true, - enabled: true + enabled: true, + deleted: false ), - custom: ServerCfg( + custom: UserServer( + serverId: 2, server: "smp://abcd@smp9.simplex.im", preset: false, tested: false, - enabled: false + enabled: false, + deleted: false ), - untested: ServerCfg( + untested: UserServer( + serverId: 3, server: "smp://abcd@smp10.simplex.im", preset: false, tested: nil, - enabled: true + enabled: true, + deleted: false + ), + xftpPreset: UserServer( + serverId: 4, + server: "xftp://abcd@xftp8.simplex.im", + preset: true, + tested: true, + enabled: true, + deleted: false ) ) enum CodingKeys: CodingKey { + case serverId case server case preset case tested case enabled + case deleted } } @@ -1357,7 +1722,7 @@ public struct NetCfg: Codable, Equatable { public var hostMode: HostMode = .publicHost public var requiredHostMode = true public var sessionMode = TransportSessionMode.user - public var smpProxyMode: SMPProxyMode = .unknown + public var smpProxyMode: SMPProxyMode = .always public var smpProxyFallback: SMPProxyFallback = .allowProtected public var smpWebPort = false public var tcpConnectTimeout: Int // microseconds diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.android.kt similarity index 69% rename from apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.android.kt index af5a27be11..8b5def7451 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.android.kt @@ -1,13 +1,13 @@ -package chat.simplex.common.views.usersettings +package chat.simplex.common.views.usersettings.networkAndServers import android.Manifest import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import chat.simplex.common.model.ServerCfg +import chat.simplex.common.model.UserServer import com.google.accompanist.permissions.rememberPermissionState @Composable -actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) { +actual fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) { val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) LaunchedEffect(Unit) { cameraPermissionState.launchPermissionRequest() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 7af1d574ad..b1ce003812 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.draw.* import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp @@ -42,7 +41,6 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlin.math.absoluteValue @Composable fun AppScreen() { @@ -194,6 +192,13 @@ fun MainScreen() { OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} OnboardingStage.LinkAMobile -> LinkAMobile() OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) + OnboardingStage.Step3_ChooseServerOperators -> { + val modalData = remember { ModalData() } + modalData.ChooseServerOperators(true) + if (appPlatform.isDesktop) { + ModalManager.fullscreen.showInView() + } + } // Ensure backwards compatibility with old onboarding stage for address creation, otherwise notification setup would be skipped OnboardingStage.Step3_CreateSimpleXAddress -> SetNotificationsMode(chatModel) OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ef777f151f..e501ed5a91 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -145,6 +145,8 @@ object ChatModel { val clipboardHasText = mutableStateOf(false) val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true)) + val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty) + val updatingProgress = mutableStateOf(null as Float?) var updatingRequest: Closeable? = null @@ -2567,6 +2569,13 @@ fun localTimestamp(t: Instant): String { return ts.toJavaLocalDateTime().format(dateFormatter) } +fun localDate(t: Instant): String { + val tz = TimeZone.currentSystemDefault() + val ts: LocalDateTime = t.toLocalDateTime(tz) + val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + return ts.toJavaLocalDateTime().format(dateFormatter) +} + @Serializable sealed class CIStatus { @Serializable @SerialName("sndNew") class SndNew: CIStatus() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7f7f8a6e58..0cab7ce8e9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -26,10 +26,12 @@ import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings +import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel @@ -963,36 +965,6 @@ object ChatController { return null } - suspend fun getUserProtoServers(rh: Long?, serverProtocol: ServerProtocol): UserProtocolServers? { - val userId = kotlin.runCatching { currentUserId("getUserProtoServers") }.getOrElse { return null } - val r = sendCmd(rh, CC.APIGetUserProtoServers(userId, serverProtocol)) - return if (r is CR.UserProtoServers) { if (rh == null) r.servers else r.servers.copy(protoServers = r.servers.protoServers.map { it.copy(remoteHostId = rh) }) } - else { - Log.e(TAG, "getUserProtoServers bad response: ${r.responseType} ${r.details}") - AlertManager.shared.showAlertMsg( - generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_loading_smp_servers else MR.strings.error_loading_xftp_servers), - "${r.responseType}: ${r.details}" - ) - null - } - } - - suspend fun setUserProtoServers(rh: Long?, serverProtocol: ServerProtocol, servers: List): Boolean { - val userId = kotlin.runCatching { currentUserId("setUserProtoServers") }.getOrElse { return false } - val r = sendCmd(rh, CC.APISetUserProtoServers(userId, serverProtocol, servers)) - return when (r) { - is CR.CmdOk -> true - else -> { - Log.e(TAG, "setUserProtoServers bad response: ${r.responseType} ${r.details}") - AlertManager.shared.showAlertMsg( - generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_saving_smp_servers else MR.strings.error_saving_xftp_servers), - generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.ensure_smp_server_address_are_correct_format_and_unique else MR.strings.ensure_xftp_server_address_are_correct_format_and_unique) - ) - false - } - } - } - suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? { val userId = currentUserId("testProtoServer") val r = sendCmd(rh, CC.APITestProtoServer(userId, server)) @@ -1005,6 +977,106 @@ object ChatController { } } + suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? { + val r = sendCmd(rh, CC.ApiGetServerOperators()) + + return when (r) { + is CR.ServerOperatorConditions -> r.conditions + else -> { + Log.e(TAG, "getServerOperators bad response: ${r.responseType} ${r.details}") + null + } + } + } + + suspend fun setServerOperators(rh: Long?, operators: List): ServerOperatorConditionsDetail? { + val r = sendCmd(rh, CC.ApiSetServerOperators(operators)) + return when (r) { + is CR.ServerOperatorConditions -> r.conditions + else -> { + Log.e(TAG, "setServerOperators bad response: ${r.responseType} ${r.details}") + null + } + } + } + + suspend fun getUserServers(rh: Long?): List? { + val userId = currentUserId("getUserServers") + val r = sendCmd(rh, CC.ApiGetUserServers(userId)) + return when (r) { + is CR.UserServers -> r.userServers + else -> { + Log.e(TAG, "getUserServers bad response: ${r.responseType} ${r.details}") + null + } + } + } + + suspend fun setUserServers(rh: Long?, userServers: List): Boolean { + val userId = currentUserId("setUserServers") + val r = sendCmd(rh, CC.ApiSetUserServers(userId, userServers)) + return when (r) { + is CR.CmdOk -> true + else -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.failed_to_save_servers), + "${r.responseType}: ${r.details}" + ) + Log.e(TAG, "setUserServers bad response: ${r.responseType} ${r.details}") + false + } + } + } + + suspend fun validateServers(rh: Long?, userServers: List): List? { + val userId = currentUserId("validateServers") + val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers)) + return when (r) { + is CR.UserServersValidation -> r.serverErrors + else -> { + Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}") + null + } + } + } + + suspend fun getUsageConditions(rh: Long?): Triple? { + val r = sendCmd(rh, CC.ApiGetUsageConditions()) + return when (r) { + is CR.UsageConditions -> Triple(r.usageConditions, r.conditionsText, r.acceptedConditions) + else -> { + Log.e(TAG, "getUsageConditions bad response: ${r.responseType} ${r.details}") + null + } + } + } + + suspend fun setConditionsNotified(rh: Long?, conditionsId: Long): Boolean { + val r = sendCmd(rh, CC.ApiSetConditionsNotified(conditionsId)) + return when (r) { + is CR.CmdOk -> true + else -> { + Log.e(TAG, "setConditionsNotified bad response: ${r.responseType} ${r.details}") + false + } + } + } + + suspend fun acceptConditions(rh: Long?, conditionsId: Long, operatorIds: List): ServerOperatorConditionsDetail? { + val r = sendCmd(rh, CC.ApiAcceptConditions(conditionsId, operatorIds)) + return when (r) { + is CR.ServerOperatorConditions -> r.conditions + else -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.error_accepting_operator_conditions), + "${r.responseType}: ${r.details}" + ) + Log.e(TAG, "acceptConditions bad response: ${r.responseType} ${r.details}") + null + } + } + } + suspend fun getChatItemTTL(rh: Long?): ChatItemTTL { val userId = currentUserId("getChatItemTTL") val r = sendCmd(rh, CC.APIGetChatItemTTL(userId)) @@ -3037,9 +3109,15 @@ sealed class CC { class APIGetGroupLink(val groupId: Long): CC() class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC() class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC() - class APIGetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol): CC() - class APISetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol, val servers: List): CC() class APITestProtoServer(val userId: Long, val server: String): CC() + class ApiGetServerOperators(): CC() + class ApiSetServerOperators(val operators: List): CC() + class ApiGetUserServers(val userId: Long): CC() + class ApiSetUserServers(val userId: Long, val userServers: List): CC() + class ApiValidateServers(val userId: Long, val userServers: List): CC() + class ApiGetUsageConditions(): CC() + class ApiSetConditionsNotified(val conditionsId: Long): CC() + class ApiAcceptConditions(val conditionsId: Long, val operatorIds: List): CC() class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC() class APIGetChatItemTTL(val userId: Long): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() @@ -3197,9 +3275,15 @@ sealed class CC { is APIGetGroupLink -> "/_get link #$groupId" is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId" is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" - is APIGetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()}" - is APISetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()} ${protoServersStr(servers)}" is APITestProtoServer -> "/_server test $userId $server" + is ApiGetServerOperators -> "/_operators" + is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}" + is ApiGetUserServers -> "/_servers $userId" + is ApiSetUserServers -> "/_servers $userId ${json.encodeToString(userServers)}" + is ApiValidateServers -> "/_validate_servers $userId ${json.encodeToString(userServers)}" + is ApiGetUsageConditions -> "/_conditions" + is ApiSetConditionsNotified -> "/_conditions_notified ${conditionsId}" + is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}" is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" is APIGetChatItemTTL -> "/_ttl $userId" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" @@ -3342,9 +3426,15 @@ sealed class CC { is APIGetGroupLink -> "apiGetGroupLink" is APICreateMemberContact -> "apiCreateMemberContact" is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" - is APIGetUserProtoServers -> "apiGetUserProtoServers" - is APISetUserProtoServers -> "apiSetUserProtoServers" is APITestProtoServer -> "testProtoServer" + is ApiGetServerOperators -> "apiGetServerOperators" + is ApiSetServerOperators -> "apiSetServerOperators" + is ApiGetUserServers -> "apiGetUserServers" + is ApiSetUserServers -> "apiSetUserServers" + is ApiValidateServers -> "apiValidateServers" + is ApiGetUsageConditions -> "apiGetUsageConditions" + is ApiSetConditionsNotified -> "apiSetConditionsNotified" + is ApiAcceptConditions -> "apiAcceptConditions" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" is APISetNetworkConfig -> "apiSetNetworkConfig" @@ -3459,8 +3549,6 @@ sealed class CC { companion object { fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}" - - fun protoServersStr(servers: List) = json.encodeToString(ProtoServersConfig(servers)) } } @@ -3510,24 +3598,350 @@ enum class ServerProtocol { } @Serializable -data class ProtoServersConfig( - val servers: List +enum class OperatorTag { + @SerialName("simplex") SimpleX, + @SerialName("flux") Flux, + @SerialName("xyz") XYZ, + @SerialName("demo") Demo +} + +data class ServerOperatorInfo( + val description: List, + val website: String, + val logo: ImageResource, + val largeLogo: ImageResource, + val logoDarkMode: ImageResource, + val largeLogoDarkMode: ImageResource +) +val operatorsInfo: Map = mapOf( + OperatorTag.SimpleX to ServerOperatorInfo( + description = listOf( + "SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.", + "SimpleX Chat Ltd develops the communication software for SimpleX network." + ), + website = "https://simplex.chat", + logo = MR.images.decentralized, + largeLogo = MR.images.logo, + logoDarkMode = MR.images.decentralized_light, + largeLogoDarkMode = MR.images.logo_light + ), + OperatorTag.Flux to ServerOperatorInfo( + description = listOf( + "Flux is the largest decentralized cloud infrastructure, leveraging a global network of user-operated computational nodes.", + "Flux offers a powerful, scalable, and affordable platform designed to support individuals, businesses, and cutting-edge technologies like AI. With high uptime and worldwide distribution, Flux ensures reliable, accessible cloud computing for all." + ), + website = "https://runonflux.com", + logo = MR.images.flux_logo_symbol, + largeLogo = MR.images.flux_logo, + logoDarkMode = MR.images.flux_logo_symbol, + largeLogoDarkMode = MR.images.flux_logo_light + ), + OperatorTag.XYZ to ServerOperatorInfo( + description = listOf("XYZ servers"), + website = "XYZ website", + logo = MR.images.shield, + largeLogo = MR.images.logo, + logoDarkMode = MR.images.shield, + largeLogoDarkMode = MR.images.logo_light + ), + OperatorTag.Demo to ServerOperatorInfo( + description = listOf("Demo operator"), + website = "Demo website", + logo = MR.images.decentralized, + largeLogo = MR.images.logo, + logoDarkMode = MR.images.decentralized_light, + largeLogoDarkMode = MR.images.logo_light + ) ) @Serializable -data class UserProtocolServers( - val serverProtocol: ServerProtocol, - val protoServers: List, - val presetServers: List, +data class UsageConditionsDetail( + val conditionsId: Long, + val conditionsCommit: String, + val notifiedAt: Instant?, + val createdAt: Instant +) { + companion object { + val sampleData = UsageConditionsDetail( + conditionsId = 1, + conditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c", + notifiedAt = null, + createdAt = Clock.System.now() + ) + } +} + +@Serializable +sealed class UsageConditionsAction { + @Serializable @SerialName("review") data class Review(val operators: List, val deadline: Instant?, val showNotice: Boolean) : UsageConditionsAction() + @Serializable @SerialName("accepted") data class Accepted(val operators: List) : UsageConditionsAction() + + val shouldShowNotice: Boolean + get() = when (this) { + is Review -> showNotice + else -> false + } +} + +@Serializable +data class ServerOperatorConditionsDetail( + val serverOperators: List, + val currentConditions: UsageConditionsDetail, + val conditionsAction: UsageConditionsAction? +) { + companion object { + val empty = ServerOperatorConditionsDetail( + serverOperators = emptyList(), + currentConditions = UsageConditionsDetail(conditionsId = 0, conditionsCommit = "empty", notifiedAt = null, createdAt = Clock.System.now()), + conditionsAction = null + ) + } +} + +@Serializable() +sealed class ConditionsAcceptance { + @Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?) : ConditionsAcceptance() + @Serializable @SerialName("required") data class Required(val deadline: Instant?) : ConditionsAcceptance() + + val conditionsAccepted: Boolean + get() = when (this) { + is Accepted -> true + is Required -> false + } + + val usageAllowed: Boolean + get() = when (this) { + is Accepted -> true + is Required -> this.deadline != null + } +} + +@Serializable +data class ServerOperator( + val operatorId: Long, + val operatorTag: OperatorTag?, + val tradeName: String, + val legalName: String?, + val serverDomains: List, + val conditionsAcceptance: ConditionsAcceptance, + val enabled: Boolean, + val smpRoles: ServerRoles, + val xftpRoles: ServerRoles, +) { + companion object { + val dummyOperatorInfo = ServerOperatorInfo( + description = listOf("Default"), + website = "Default", + logo = MR.images.decentralized, + largeLogo = MR.images.logo, + logoDarkMode = MR.images.decentralized_light, + largeLogoDarkMode = MR.images.logo_light + ) + + val sampleData1 = ServerOperator( + operatorId = 1, + operatorTag = OperatorTag.SimpleX, + tradeName = "SimpleX Chat", + legalName = "SimpleX Chat Ltd", + serverDomains = listOf("simplex.im"), + conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null), + enabled = true, + smpRoles = ServerRoles(storage = true, proxy = true), + xftpRoles = ServerRoles(storage = true, proxy = true) + ) + + val sampleData2 = ServerOperator( + operatorId = 2, + operatorTag = OperatorTag.XYZ, + tradeName = "XYZ", + legalName = null, + serverDomains = listOf("xyz.com"), + conditionsAcceptance = ConditionsAcceptance.Required(deadline = null), + enabled = false, + smpRoles = ServerRoles(storage = false, proxy = true), + xftpRoles = ServerRoles(storage = false, proxy = true) + ) + + val sampleData3 = ServerOperator( + operatorId = 3, + operatorTag = OperatorTag.Demo, + tradeName = "Demo", + legalName = null, + serverDomains = listOf("demo.com"), + conditionsAcceptance = ConditionsAcceptance.Required(deadline = null), + enabled = false, + smpRoles = ServerRoles(storage = true, proxy = false), + xftpRoles = ServerRoles(storage = true, proxy = false) + ) + } + + val id: Long + get() = operatorId + + override fun equals(other: Any?): Boolean { + if (other !is ServerOperator) return false + return other.operatorId == this.operatorId && + other.operatorTag == this.operatorTag && + other.tradeName == this.tradeName && + other.legalName == this.legalName && + other.serverDomains == this.serverDomains && + other.conditionsAcceptance == this.conditionsAcceptance && + other.enabled == this.enabled && + other.smpRoles == this.smpRoles && + other.xftpRoles == this.xftpRoles + } + + override fun hashCode(): Int { + var result = operatorId.hashCode() + result = 31 * result + (operatorTag?.hashCode() ?: 0) + result = 31 * result + tradeName.hashCode() + result = 31 * result + (legalName?.hashCode() ?: 0) + result = 31 * result + serverDomains.hashCode() + result = 31 * result + conditionsAcceptance.hashCode() + result = 31 * result + enabled.hashCode() + result = 31 * result + smpRoles.hashCode() + result = 31 * result + xftpRoles.hashCode() + return result + } + + val legalName_: String + get() = legalName ?: tradeName + + val info: ServerOperatorInfo get() { + return if (this.operatorTag != null) { + operatorsInfo[this.operatorTag] ?: dummyOperatorInfo + } else { + dummyOperatorInfo + } + } + + val logo: ImageResource + @Composable + get() { + return if (isInDarkTheme()) info.logoDarkMode else info.logo + } + + val largeLogo: ImageResource + @Composable + get() { + return if (isInDarkTheme()) info.largeLogoDarkMode else info.largeLogo + } +} + +@Serializable +data class ServerRoles( + val storage: Boolean, + val proxy: Boolean ) @Serializable -data class ServerCfg( +data class UserOperatorServers( + val operator: ServerOperator?, + val smpServers: List, + val xftpServers: List +) { + val id: String + get() = operator?.operatorId?.toString() ?: "nil operator" + + val operator_: ServerOperator + get() = operator ?: ServerOperator( + operatorId = 0, + operatorTag = null, + tradeName = "", + legalName = null, + serverDomains = emptyList(), + conditionsAcceptance = ConditionsAcceptance.Accepted(null), + enabled = false, + smpRoles = ServerRoles(storage = true, proxy = true), + xftpRoles = ServerRoles(storage = true, proxy = true) + ) + + companion object { + val sampleData1 = UserOperatorServers( + operator = ServerOperator.sampleData1, + smpServers = listOf(UserServer.sampleData.preset), + xftpServers = listOf(UserServer.sampleData.xftpPreset) + ) + + val sampleDataNilOperator = UserOperatorServers( + operator = null, + smpServers = listOf(UserServer.sampleData.preset), + xftpServers = listOf(UserServer.sampleData.xftpPreset) + ) + } +} + +@Serializable +sealed class UserServersError { + @Serializable @SerialName("noServers") data class NoServers(val protocol: ServerProtocol, val user: UserRef?): UserServersError() + @Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() + @Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() + @Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError() + + val globalError: String? + get() = when (this.protocol_) { + ServerProtocol.SMP -> globalSMPError + ServerProtocol.XFTP -> globalXFTPError + } + + private val protocol_: ServerProtocol + get() = when (this) { + is NoServers -> this.protocol + is StorageMissing -> this.protocol + is ProxyMissing -> this.protocol + is DuplicateServer -> this.protocol + } + + val globalSMPError: String? + get() = if (this.protocol_ == ServerProtocol.SMP) { + when (this) { + is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured)}" } + ?: generalGetString(MR.strings.no_message_servers_configured) + + is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_receiving)}" } + ?: generalGetString(MR.strings.no_message_servers_configured_for_receiving) + + is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_private_routing)}" } + ?: generalGetString(MR.strings.no_message_servers_configured_for_private_routing) + + else -> null + } + } else { + null + } + + val globalXFTPError: String? + get() = if (this.protocol_ == ServerProtocol.XFTP) { + when (this) { + is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured)}" } + ?: generalGetString(MR.strings.no_media_servers_configured) + + is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_sending)}" } + ?: generalGetString(MR.strings.no_media_servers_configured_for_sending) + + is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_private_routing)}" } + ?: generalGetString(MR.strings.no_media_servers_configured_for_private_routing) + + else -> null + } + } else { + null + } + + private fun userStr(user: UserRef): String { + return String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + } +} + +@Serializable +data class UserServer( val remoteHostId: Long?, + val serverId: Long?, val server: String, val preset: Boolean, val tested: Boolean? = null, - val enabled: Boolean + val enabled: Boolean, + val deleted: Boolean ) { @Transient private val createdAt: Date = Date() @@ -3541,35 +3955,51 @@ data class ServerCfg( get() = server.isBlank() companion object { - val empty = ServerCfg(remoteHostId = null, server = "", preset = false, tested = null, enabled = false) + val empty = UserServer(remoteHostId = null, serverId = null, server = "", preset = false, tested = null, enabled = false, deleted = false) class SampleData( - val preset: ServerCfg, - val custom: ServerCfg, - val untested: ServerCfg + val preset: UserServer, + val custom: UserServer, + val untested: UserServer, + val xftpPreset: UserServer ) val sampleData = SampleData( - preset = ServerCfg( + preset = UserServer( remoteHostId = null, + serverId = 1, server = "smp://abcd@smp8.simplex.im", preset = true, tested = true, - enabled = true + enabled = true, + deleted = false ), - custom = ServerCfg( + custom = UserServer( remoteHostId = null, + serverId = 2, server = "smp://abcd@smp9.simplex.im", preset = false, tested = false, - enabled = false + enabled = false, + deleted = false ), - untested = ServerCfg( + untested = UserServer( remoteHostId = null, + serverId = 3, server = "smp://abcd@smp10.simplex.im", preset = false, tested = null, - enabled = true + enabled = true, + deleted = false + ), + xftpPreset = UserServer( + remoteHostId = null, + serverId = 4, + server = "xftp://abcd@xftp8.simplex.im", + preset = true, + tested = true, + enabled = true, + deleted = false ) ) } @@ -3683,7 +4113,7 @@ data class NetCfg( val hostMode: HostMode = HostMode.OnionViaSocks, val requiredHostMode: Boolean = false, val sessionMode: TransportSessionMode = TransportSessionMode.default, - val smpProxyMode: SMPProxyMode = SMPProxyMode.Unknown, + val smpProxyMode: SMPProxyMode = SMPProxyMode.Always, val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.AllowProtected, val smpWebPort: Boolean = false, val tcpConnectTimeout: Long, // microseconds @@ -4928,8 +5358,11 @@ sealed class CR { @Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List): CR() @Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR() @Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR() - @Serializable @SerialName("userProtoServers") class UserProtoServers(val user: UserRef, val servers: UserProtocolServers): CR() @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR() + @Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR() + @Serializable @SerialName("userServers") class UserServers(val user: UserRef, val userServers: List): CR() + @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List): CR() + @Serializable @SerialName("usageConditions") class UsageConditions(val usageConditions: UsageConditionsDetail, val conditionsText: String?, val acceptedConditions: UsageConditionsDetail?): CR() @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() @Serializable @SerialName("contactInfo") class ContactInfo(val user: UserRef, val contact: Contact, val connectionStats_: ConnectionStats? = null, val customUserProfile: Profile? = null): CR() @@ -5108,8 +5541,11 @@ sealed class CR { is ApiChats -> "apiChats" is ApiChat -> "apiChat" is ApiChatItemInfo -> "chatItemInfo" - is UserProtoServers -> "userProtoServers" is ServerTestResult -> "serverTestResult" + is ServerOperatorConditions -> "serverOperatorConditions" + is UserServers -> "userServers" + is UserServersValidation -> "userServersValidation" + is UsageConditions -> "usageConditions" is ChatItemTTL -> "chatItemTTL" is NetworkConfig -> "networkConfig" is ContactInfo -> "contactInfo" @@ -5278,8 +5714,11 @@ sealed class CR { is ApiChats -> withUser(user, json.encodeToString(chats)) is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") - is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") + is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}" + is UserServers -> withUser(user, "userServers: ${json.encodeToString(userServers)}") + is UserServersValidation -> withUser(user, "serverErrors: ${json.encodeToString(serverErrors)}") + is UsageConditions -> "usageConditions: ${json.encodeToString(usageConditions)}\nnacceptedConditions: ${json.encodeToString(acceptedConditions)}" is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL)) is NetworkConfig -> json.encodeToString(networkConfig) is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 79132b5eb1..08ca72c6bd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -118,6 +118,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) val user = chatController.apiGetActiveUser(null) chatModel.currentUser.value = user + chatModel.conditions.value = chatController.getServerOperators(null) ?: ServerOperatorConditionsDetail.empty if (user == null) { chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = null @@ -137,13 +138,12 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } } else if (startChat().await()) { val savedOnboardingStage = appPreferences.onboardingStage.get() - val next = if (appPlatform.isAndroid) { - OnboardingStage.Step4_SetNotificationsMode - } else { - OnboardingStage.OnboardingComplete - } val newStage = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { - next + if (appPlatform.isAndroid) { + OnboardingStage.Step4_SetNotificationsMode + } else { + OnboardingStage.OnboardingComplete + } } else { savedOnboardingStage } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 17658d23e8..15d38c5490 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -165,7 +165,7 @@ fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) { if (!chatModel.connectedToRemote()) { chatModel.localUserCreated.value = true } - controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) + controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators) controller.startChat(user) controller.switchUIRemoteHost(null) close() @@ -204,7 +204,7 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) { OnboardingStage.Step2_5_SetupDatabasePassphrase } else { - OnboardingStage.Step4_SetNotificationsMode + OnboardingStage.Step3_ChooseServerOperators }) } else { // the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 2aee98acba..79f97d9f6b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1260,7 +1260,7 @@ fun BoxScope.ChatItemsList( } else { null } - val showAvatar = if (merged is MergedItem.Grouped) shouldShowAvatar(item, listItem.nextItem) else true + val showAvatar = shouldShowAvatar(item, listItem.nextItem) val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } val itemSeparation: ItemSeparation val prevItemSeparationLargeGap: Boolean diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 9661a305cc..20bb65ec7d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -25,19 +25,21 @@ import androidx.compose.ui.unit.* import chat.simplex.common.AppLock import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatController.setConditionsNotified import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.WhatsNewView -import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call import chat.simplex.common.views.chat.item.CIFileViewScope import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.mkValidName import chat.simplex.common.views.newchat.* +import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.showInvalidNameAlert import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton +import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow @@ -115,10 +117,26 @@ fun ToggleChatListCard() { @Composable fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { val oneHandUI = remember { appPrefs.oneHandUI.state } + val rhId = chatModel.remoteHostId() + LaunchedEffect(Unit) { - if (shouldShowWhatsNew(chatModel)) { + val showWhatsNew = shouldShowWhatsNew(chatModel) + val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false + if (showWhatsNew) { delay(1000L) - ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) } + ModalManager.center.showCustomModal { close -> WhatsNewView(close = close, updatedConditions = showUpdatedConditions) } + } else if (showUpdatedConditions) { + ModalManager.center.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + LaunchedEffect(Unit) { + val conditionsId = chatModel.conditions.value.currentConditions.conditionsId + try { + setConditionsNotified(rh = rhId, conditionsId = conditionsId) + } catch (e: Exception) { + Log.d(TAG, "UsageConditionsView setConditionsNotified error: ${e.message}") + } + } + UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index 4e3ee2340c..acbc72ff48 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -48,7 +48,6 @@ import chat.simplex.common.model.localTimestamp import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.usersettings.ProtocolServersView import chat.simplex.common.views.usersettings.SettingsPreferenceItem import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @@ -540,15 +539,8 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant, ) ) } - if (summary.known == true) { - SectionItemView(click = { - ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.XFTP, close) } - }) { - Text(generalGetString(MR.strings.open_server_settings_button)) - } - if (summary.stats != null || summary.sessions != null) { - SectionDividerSpaced() - } + if (summary.stats != null || summary.sessions != null) { + SectionDividerSpaced() } if (summary.stats != null) { @@ -579,12 +571,7 @@ fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, r ) ) } - if (summary.known == true) { - SectionItemView(click = { - ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.SMP, close) } - }) { - Text(generalGetString(MR.strings.open_server_settings_button)) - } + if (summary.stats != null || summary.subs != null || summary.sessions != null) { SectionDividerSpaced() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt index 195ec020e5..afb557cc78 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt @@ -17,11 +17,21 @@ import dev.icerock.moko.resources.compose.painterResource import kotlin.math.absoluteValue @Composable -fun AppBarTitle(title: String, hostDevice: Pair? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) { +fun AppBarTitle( + title: String, + hostDevice: Pair? = null, + withPadding: Boolean = true, + bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp, + enableAlphaChanges: Boolean = true +) { val handler = LocalAppBarHandler.current - val connection = handler?.connection + val connection = if (enableAlphaChanges) handler?.connection else null LaunchedEffect(title) { - handler?.title?.value = title + if (enableAlphaChanges) { + handler?.title?.value = title + } else { + handler?.connection?.scrollTrackingEnabled = false + } } val theme = CurrentColors.collectAsState() val titleColor = MaterialTheme.appColors.title @@ -54,7 +64,8 @@ fun AppBarTitle(title: String, hostDevice: Pair? = null, withPad } private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) = - if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f + if (connection?.scrollTrackingEnabled == false) 1f + else if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt index 50942169b3..ad6611b9d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt @@ -84,6 +84,7 @@ class AppBarHandler( } class CollapsingAppBarNestedScrollConnection(): NestedScrollConnection { + var scrollTrackingEnabled = true var appBarOffset: Float by mutableFloatStateOf(0f) override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index cf0c5f7e96..4bf20d2128 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -258,7 +258,8 @@ private fun AppBarCenterAligned( } private fun topTitleAlpha(text: Boolean, connection: CollapsingAppBarNestedScrollConnection, alpha: Float = appPrefs.inAppBarsAlpha.get()) = - if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f + if (!connection.scrollTrackingEnabled) 0f + else if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, if (text) 1f else alpha) val AppBarHeight = 56.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 90f8593c4a..28ec77de70 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt new file mode 100644 index 0000000000..8b383e0146 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -0,0 +1,354 @@ +package chat.simplex.common.views.onboarding + +import SectionBottomSpacer +import SectionTextFooter +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ServerOperator +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.networkAndServers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun ModalData.ChooseServerOperators( + onboarding: Boolean, + close: (() -> Unit) = { ModalManager.fullscreen.closeModals() }, + modalManager: ModalManager = ModalManager.fullscreen +) { + LaunchedEffect(Unit) { + prepareChatBeforeFinishingOnboarding() + } + + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + ModalView({}, showClose = false, endButtons = { + IconButton({ modalManager.showModal { ChooseServerOperatorsInfoView() } }) { + Icon(painterResource(MR.images.ic_info), null, Modifier.size(28.dp), tint = MaterialTheme.colors.primary) + } + }) { + val serverOperators = remember { derivedStateOf { chatModel.conditions.value.serverOperators } } + val selectedOperatorIds = remember { stateGetOrPut("selectedOperatorIds") { serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() } } + val selectedOperators = remember { derivedStateOf { serverOperators.value.filter { selectedOperatorIds.value.contains(it.operatorId) } } } + + ColumnWithScrollBar( + Modifier + .themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer), + maxIntrinsicSize = true + ) { + Box(Modifier.align(Alignment.CenterHorizontally)) { + AppBarTitle(stringResource(MR.strings.onboarding_choose_server_operators)) + } + Column(( + if (appPlatform.isDesktop) Modifier.width(600.dp).align(Alignment.CenterHorizontally) else Modifier) + .padding(horizontal = DEFAULT_PADDING) + ) { + Text(stringResource(MR.strings.onboarding_select_network_operators_to_use)) + Spacer(Modifier.height(DEFAULT_PADDING)) + } + Spacer(Modifier.weight(1f)) + Column(( + if (appPlatform.isDesktop) Modifier.width(600.dp).align(Alignment.CenterHorizontally) else Modifier) + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING) + ) { + serverOperators.value.forEachIndexed { index, srvOperator -> + OperatorCheckView(srvOperator, selectedOperatorIds) + if (index != serverOperators.value.lastIndex) { + Spacer(Modifier.height(DEFAULT_PADDING)) + } + } + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + + SectionTextFooter(annotatedStringResource(MR.strings.onboarding_network_operators_configure_via_settings), textAlign = TextAlign.Center) + } + Spacer(Modifier.weight(1f)) + + val reviewForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } + val canReviewLater = reviewForOperators.all { it.conditionsAcceptance.usageAllowed } + val currEnabledOperatorIds = serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() + + Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + val enabled = selectedOperatorIds.value.isNotEmpty() + when { + reviewForOperators.isNotEmpty() -> ReviewConditionsButton(enabled, onboarding, selectedOperators, selectedOperatorIds, modalManager) + selectedOperatorIds.value != currEnabledOperatorIds && enabled -> SetOperatorsButton(true, onboarding, serverOperators, selectedOperatorIds, close) + else -> ContinueButton(enabled, onboarding, close) + } + if (onboarding && reviewForOperators.isEmpty()) { + TextButtonBelowOnboardingButton(stringResource(MR.strings.operator_conditions_of_use)) { + modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + UsageConditionsView( + currUserServers = remember { mutableStateOf(emptyList()) }, + userServers = remember { mutableStateOf(emptyList()) }, + close = close, + rhId = null + ) + } + } + } else if (onboarding || reviewForOperators.isEmpty()) { + // Reserve space + TextButtonBelowOnboardingButton("", null) + } + if (!onboarding && reviewForOperators.isNotEmpty()) { + ReviewLaterButton(canReviewLater, close) + SectionTextFooter( + annotatedStringResource(MR.strings.onboarding_network_operators_conditions_will_be_accepted) + + AnnotatedString(" ") + + annotatedStringResource(MR.strings.onboarding_network_operators_conditions_you_can_configure), + textAlign = TextAlign.Center + ) + SectionBottomSpacer() + } + } + } + } + } +} + +@Composable +private fun OperatorCheckView(serverOperator: ServerOperator, selectedOperatorIds: MutableState>) { + val checked = selectedOperatorIds.value.contains(serverOperator.operatorId) + TextButton({ + if (checked) { + selectedOperatorIds.value -= serverOperator.operatorId + } else { + selectedOperatorIds.value += serverOperator.operatorId + } + }, + border = BorderStroke(1.dp, color = if (checked) MaterialTheme.colors.primary else MaterialTheme.colors.secondary.copy(alpha = 0.5f)), + shape = RoundedCornerShape(18.dp) + ) { + Row(Modifier.padding(DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically) { + Image(painterResource(serverOperator.largeLogo), null, Modifier.height(48.dp)) + Spacer(Modifier.width(DEFAULT_PADDING_HALF).weight(1f)) + CircleCheckbox(checked) + } + } +} + +@Composable +private fun CircleCheckbox(checked: Boolean) { + if (checked) { + Box(contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_circle_filled), + null, + Modifier.size(26.dp), + tint = MaterialTheme.colors.primary + ) + Icon( + painterResource(MR.images.ic_check_filled), + null, + Modifier.size(20.dp), tint = MaterialTheme.colors.background + ) + } + } else { + Icon( + painterResource(MR.images.ic_circle), + null, + Modifier.size(26.dp), + tint = MaterialTheme.colors.secondary.copy(alpha = 0.5f) + ) + } +} + +@Composable +private fun ReviewConditionsButton( + enabled: Boolean, + onboarding: Boolean, + selectedOperators: State>, + selectedOperatorIds: State>, + modalManager: ModalManager +) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier, + labelId = MR.strings.operator_review_conditions, + onboarding = null, + enabled = enabled, + onclick = { + modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + ReviewConditionsView(onboarding, selectedOperators, selectedOperatorIds, close) + } + } + ) +} + +@Composable +private fun SetOperatorsButton(enabled: Boolean, onboarding: Boolean, serverOperators: State>, selectedOperatorIds: State>, close: () -> Unit) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier, + labelId = MR.strings.onboarding_network_operators_update, + onboarding = null, + enabled = enabled, + onclick = { + withBGApi { + val enabledOperators = enabledOperators(serverOperators.value, selectedOperatorIds.value) + if (enabledOperators != null) { + val r = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators) + if (r != null) { + chatModel.conditions.value = r + } + continueToNextStep(onboarding, close) + } + } + } + ) +} + +@Composable +private fun ContinueButton(enabled: Boolean, onboarding: Boolean, close: () -> Unit) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier, + labelId = MR.strings.onboarding_network_operators_continue, + onboarding = null, + enabled = enabled, + onclick = { + continueToNextStep(onboarding, close) + } + ) +} + +@Composable +private fun ReviewLaterButton(enabled: Boolean, close: () -> Unit) { + TextButtonBelowOnboardingButton( + stringResource(MR.strings.onboarding_network_operators_review_later), + onClick = if (!enabled) null else {{ continueToNextStep(false, close) }} + ) +} + +@Composable +private fun ReviewConditionsView( + onboarding: Boolean, + selectedOperators: State>, + selectedOperatorIds: State>, + close: () -> Unit +) { + // remembering both since we don't want to reload the view after the user accepts conditions + val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } } + val acceptForOperators = remember { selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } } + ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), withPadding = false, enableAlphaChanges = false) + if (operatorsWithConditionsAccepted.isNotEmpty()) { + ReadableText(MR.strings.operator_conditions_accepted_for_some, args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ }) + ReadableText(MR.strings.operator_same_conditions_will_apply_to_operators, args = acceptForOperators.joinToString(", ") { it.legalName_ }) + } else { + ReadableText(MR.strings.operator_conditions_will_be_accepted_for_some, args = acceptForOperators.joinToString(", ") { it.legalName_ }) + } + Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(chatModel.remoteHostId()) + } + Column(Modifier.padding(top = DEFAULT_PADDING).widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + AcceptConditionsButton(onboarding, selectedOperators, selectedOperatorIds, close) + // Reserve space + TextButtonBelowOnboardingButton("", null) + } + } +} + +@Composable +private fun AcceptConditionsButton( + onboarding: Boolean, + selectedOperators: State>, + selectedOperatorIds: State>, + close: () -> Unit +) { + fun continueOnAccept() { + if (appPlatform.isDesktop || !onboarding) { + if (onboarding) { close() } + continueToNextStep(onboarding, close) + } else { + continueToSetNotificationsAfterAccept() + } + } + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier, + labelId = MR.strings.accept_conditions, + onboarding = null, + onclick = { + withBGApi { + val conditionsId = chatModel.conditions.value.currentConditions.conditionsId + val acceptForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } + val operatorIds = acceptForOperators.map { it.operatorId } + val r = chatController.acceptConditions(chatModel.remoteHostId(), conditionsId = conditionsId, operatorIds = operatorIds) + if (r != null) { + chatModel.conditions.value = r + val enabledOperators = enabledOperators(r.serverOperators, selectedOperatorIds.value) + if (enabledOperators != null) { + val r2 = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators) + if (r2 != null) { + chatModel.conditions.value = r2 + continueOnAccept() + } + } else { + continueOnAccept() + } + } + } + } + ) +} + +private fun continueToNextStep(onboarding: Boolean, close: () -> Unit) { + if (onboarding) { + appPrefs.onboardingStage.set(if (appPlatform.isAndroid) OnboardingStage.Step4_SetNotificationsMode else OnboardingStage.OnboardingComplete) + } else { + close() + } +} + +private fun continueToSetNotificationsAfterAccept() { + appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) + ModalManager.fullscreen.showModalCloseable(showClose = false) { SetNotificationsMode(chatModel) } +} + +private fun enabledOperators(operators: List, selectedOperatorIds: Set): List? { + val ops = ArrayList(operators) + if (ops.isNotEmpty()) { + for (i in ops.indices) { + val op = ops[i] + ops[i] = op.copy(enabled = selectedOperatorIds.contains(op.operatorId)) + } + val haveSMPStorage = ops.any { it.enabled && it.smpRoles.storage } + val haveSMPProxy = ops.any { it.enabled && it.smpRoles.proxy } + val haveXFTPStorage = ops.any { it.enabled && it.xftpRoles.storage } + val haveXFTPProxy = ops.any { it.enabled && it.xftpRoles.proxy } + val firstEnabledIndex = ops.indexOfFirst { it.enabled } + if (haveSMPStorage && haveSMPProxy && haveXFTPStorage && haveXFTPProxy) { + return ops + } else if (firstEnabledIndex != -1) { + var op = ops[firstEnabledIndex] + if (!haveSMPStorage) op = op.copy(smpRoles = op.smpRoles.copy(storage = true)) + if (!haveSMPProxy) op = op.copy(smpRoles = op.smpRoles.copy(proxy = true)) + if (!haveXFTPStorage) op = op.copy(xftpRoles = op.xftpRoles.copy(storage = true)) + if (!haveXFTPProxy) op = op.copy(xftpRoles = op.xftpRoles.copy(proxy = true)) + ops[firstEnabledIndex] = op + return ops + } else { // Shouldn't happen - view doesn't let to proceed if no operators are enabled + return null + } + } else { + return null + } +} + +@Composable +private fun ChooseServerOperatorsInfoView() { + ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.onboarding_network_operators), withPadding = false) + ReadableText(stringResource(MR.strings.onboarding_network_operators_app_will_use_different_operators)) + ReadableText(stringResource(MR.strings.onboarding_network_operators_app_will_use_for_routing)) + SectionBottomSpacer() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index 98e8ec971d..34b6209ffe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -48,8 +48,8 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference? } @Composable -fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current) { - Text(annotatedStringResource(stringResId), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style) +fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current, args: Any? = null) { + Text(annotatedStringResource(stringResId, args), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style) } @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index d4c63248e5..510df13c3d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -5,6 +5,7 @@ enum class OnboardingStage { Step2_CreateProfile, LinkAMobile, Step2_5_SetupDatabasePassphrase, + Step3_ChooseServerOperators, Step3_CreateSimpleXAddress, Step4_SetNotificationsMode, OnboardingComplete diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt index d6d5753b6c..49c91813dc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt @@ -16,8 +16,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatModel import chat.simplex.common.model.NotificationsMode -import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.changeNotificationsMode @@ -26,7 +25,7 @@ import chat.simplex.res.MR @Composable fun SetNotificationsMode(m: ChatModel) { LaunchedEffect(Unit) { - prepareChatBeforeNotificationsSetup(m) + prepareChatBeforeFinishingOnboarding() } CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { @@ -57,6 +56,7 @@ fun SetNotificationsMode(m: ChatModel) { onboarding = OnboardingStage.OnboardingComplete, onclick = { changeNotificationsMode(currentMode.value, m) + ModalManager.fullscreen.closeModals() } ) // Reserve space @@ -99,7 +99,7 @@ fun SelectableCard(currentValue: State, newValue: T, title: String, descr Spacer(Modifier.height(14.dp)) } -private fun prepareChatBeforeNotificationsSetup(chatModel: ChatModel) { +fun prepareChatBeforeFinishingOnboarding() { // No visible users but may have hidden. In this case chat should be started anyway because it's stopped on this stage with hidden users if (chatModel.users.any { u -> !u.user.hidden }) return withBGApi { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 4ad2675e83..f20cb38dad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -17,7 +17,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.database.* @@ -36,7 +35,7 @@ fun SetupDatabasePassphrase(m: ChatModel) { val confirmNewKey = rememberSaveable { mutableStateOf("") } fun nextStep() { if (appPlatform.isAndroid || chatModel.currentUser.value != null) { - m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) + m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators) } else { m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index e43404cb07..b133ae27d4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -164,14 +164,15 @@ fun OnboardingActionButton( @Composable fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?) { val state = getKeyboardState() + val enabled = onClick != null val topPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING) val bottomPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING * 2) if ((appPlatform.isAndroid && state.value == KeyboardState.Closed) || topPadding > 0.dp) { - TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = onClick != null) { + TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = enabled) { Text( text, Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF, bottom = 5.dp), - color = MaterialTheme.colors.primary, + color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index bdbef3b654..e20a56c407 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -17,17 +16,32 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.setConditionsNotified +import chat.simplex.common.model.ServerOperator.Companion.dummyOperatorInfo import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @Composable -fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) { +fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Boolean = false, close: () -> Unit) { val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) } + val rhId = chatModel.remoteHostId() + + if (updatedConditions) { + LaunchedEffect(Unit) { + val conditionsId = chatModel.conditions.value.currentConditions.conditionsId + try { + setConditionsNotified(rh = rhId, conditionsId = conditionsId) + } catch (e: Exception) { + Log.d(TAG, "WhatsNewView setConditionsNotified error: ${e.message}") + } + } + } @Composable fun featureDescription(icon: ImageResource?, titleId: StringResource, descrId: StringResource?, link: String?, subfeatures: List>) { @@ -124,9 +138,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) { ) { AppBarTitle(String.format(generalGetString(MR.strings.new_in_version), v.version), withPadding = false, bottomPadding = DEFAULT_PADDING) + val modalManager = if (viaSettings) ModalManager.start else ModalManager.center + v.features.forEach { feature -> - if (feature.show) { - featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures) + when (feature) { + is VersionFeature.FeatureDescription -> { + if (feature.show) { + featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures) + } + } + is VersionFeature.FeatureView -> { + feature.view(modalManager) + } } } @@ -134,6 +157,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) { ReadMoreButton(v.post) } + if (updatedConditions) { + Text( + stringResource(MR.strings.view_updated_conditions), + color = MaterialTheme.colors.primary, + modifier = Modifier.clickable { + modalManager.showModalCloseable { + close -> UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId) + } + } + ) + } + if (!viaSettings) { Spacer(Modifier.fillMaxHeight().weight(1f)) Box( @@ -141,7 +176,9 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) { ) { Text( generalGetString(MR.strings.ok), - modifier = Modifier.clickable(onClick = close), + modifier = Modifier.clickable(onClick = { + close() + }), style = MaterialTheme.typography.h3, color = MaterialTheme.colors.primary ) @@ -166,18 +203,26 @@ fun ReadMoreButton(url: String) { } } -private data class FeatureDescription( - val icon: ImageResource?, - val titleId: StringResource, - val descrId: StringResource?, - var subfeatures: List> = listOf(), - val link: String? = null, - val show: Boolean = true -) +private sealed class VersionFeature { + class FeatureDescription( + val icon: ImageResource?, + val titleId: StringResource, + val descrId: StringResource?, + var subfeatures: List> = listOf(), + val link: String? = null, + val show: Boolean = true + ): VersionFeature() + + class FeatureView( + val icon: ImageResource?, + val titleId: StringResource, + val view: @Composable (modalManager: ModalManager) -> Unit + ): VersionFeature() +} private data class VersionDescription( val version: String, - val features: List, + val features: List, val post: String? = null, ) @@ -186,18 +231,18 @@ private val versionDescriptions: List = listOf( version = "v4.2", post = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_verified_user, titleId = MR.strings.v4_2_security_assessment, descrId = MR.strings.v4_2_security_assessment_desc, link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html" ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_group, titleId = MR.strings.v4_2_group_links, descrId = MR.strings.v4_2_group_links_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_check, titleId = MR.strings.v4_2_auto_accept_contact_requests, descrId = MR.strings.v4_2_auto_accept_contact_requests_desc @@ -208,22 +253,22 @@ private val versionDescriptions: List = listOf( version = "v4.3", post = "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_mic, titleId = MR.strings.v4_3_voice_messages, descrId = MR.strings.v4_3_voice_messages_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_delete_forever, titleId = MR.strings.v4_3_irreversible_message_deletion, descrId = MR.strings.v4_3_irreversible_message_deletion_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_wifi_tethering, titleId = MR.strings.v4_3_improved_server_configuration, descrId = MR.strings.v4_3_improved_server_configuration_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_visibility_off, titleId = MR.strings.v4_3_improved_privacy_and_security, descrId = MR.strings.v4_3_improved_privacy_and_security_desc @@ -234,22 +279,22 @@ private val versionDescriptions: List = listOf( version = "v4.4", post = "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_timer, titleId = MR.strings.v4_4_disappearing_messages, descrId = MR.strings.v4_4_disappearing_messages_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_pending, titleId = MR.strings.v4_4_live_messages, descrId = MR.strings.v4_4_live_messages_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_verified_user, titleId = MR.strings.v4_4_verify_connection_security, descrId = MR.strings.v4_4_verify_connection_security_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v4_4_french_interface, descrId = MR.strings.v4_4_french_interface_descr @@ -260,33 +305,33 @@ private val versionDescriptions: List = listOf( version = "v4.5", post = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_manage_accounts, titleId = MR.strings.v4_5_multiple_chat_profiles, descrId = MR.strings.v4_5_multiple_chat_profiles_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_edit_note, titleId = MR.strings.v4_5_message_draft, descrId = MR.strings.v4_5_message_draft_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_safety_divider, titleId = MR.strings.v4_5_transport_isolation, descrId = MR.strings.v4_5_transport_isolation_descr, link = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation" ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_task, titleId = MR.strings.v4_5_private_filenames, descrId = MR.strings.v4_5_private_filenames_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_battery_2_bar, titleId = MR.strings.v4_5_reduced_battery_usage, descrId = MR.strings.v4_5_reduced_battery_usage_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v4_5_italian_interface, descrId = MR.strings.v4_5_italian_interface_descr, @@ -297,32 +342,32 @@ private val versionDescriptions: List = listOf( version = "v4.6", post = "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_lock, titleId = MR.strings.v4_6_hidden_chat_profiles, descrId = MR.strings.v4_6_hidden_chat_profiles_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_flag, titleId = MR.strings.v4_6_group_moderation, descrId = MR.strings.v4_6_group_moderation_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_maps_ugc, titleId = MR.strings.v4_6_group_welcome_message, descrId = MR.strings.v4_6_group_welcome_message_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_call, titleId = MR.strings.v4_6_audio_video_calls, descrId = MR.strings.v4_6_audio_video_calls_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_battery_3_bar, titleId = MR.strings.v4_6_reduced_battery_usage, descrId = MR.strings.v4_6_reduced_battery_usage_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v4_6_chinese_spanish_interface, descrId = MR.strings.v4_6_chinese_spanish_interface_descr, @@ -333,17 +378,17 @@ private val versionDescriptions: List = listOf( version = "v5.0", post = "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_upload_file, titleId = MR.strings.v5_0_large_files_support, descrId = MR.strings.v5_0_large_files_support_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_lock, titleId = MR.strings.v5_0_app_passcode, descrId = MR.strings.v5_0_app_passcode_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_0_polish_interface, descrId = MR.strings.v5_0_polish_interface_descr, @@ -354,27 +399,27 @@ private val versionDescriptions: List = listOf( version = "v5.1", post = "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_add_reaction, titleId = MR.strings.v5_1_message_reactions, descrId = MR.strings.v5_1_message_reactions_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_chat, titleId = MR.strings.v5_1_better_messages, descrId = MR.strings.v5_1_better_messages_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_light_mode, titleId = MR.strings.v5_1_custom_themes, descrId = MR.strings.v5_1_custom_themes_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_lock, titleId = MR.strings.v5_1_self_destruct_passcode, descrId = MR.strings.v5_1_self_destruct_passcode_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_1_japanese_portuguese_interface, descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate, @@ -385,27 +430,27 @@ private val versionDescriptions: List = listOf( version = "v5.2", post = "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_check, titleId = MR.strings.v5_2_message_delivery_receipts, descrId = MR.strings.v5_2_message_delivery_receipts_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_star, titleId = MR.strings.v5_2_favourites_filter, descrId = MR.strings.v5_2_favourites_filter_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_sync_problem, titleId = MR.strings.v5_2_fix_encryption, descrId = MR.strings.v5_2_fix_encryption_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_timer, titleId = MR.strings.v5_2_disappear_one_message, descrId = MR.strings.v5_2_disappear_one_message_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_redeem, titleId = MR.strings.v5_2_more_things, descrId = MR.strings.v5_2_more_things_descr @@ -416,29 +461,29 @@ private val versionDescriptions: List = listOf( version = "v5.3", post = "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_desktop, titleId = MR.strings.v5_3_new_desktop_app, descrId = MR.strings.v5_3_new_desktop_app_descr, link = "https://simplex.chat/downloads/" ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_lock, titleId = MR.strings.v5_3_encrypt_local_files, descrId = MR.strings.v5_3_encrypt_local_files_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_search, titleId = MR.strings.v5_3_discover_join_groups, descrId = MR.strings.v5_3_discover_join_groups_descr, link = "simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion" ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_theater_comedy, titleId = MR.strings.v5_3_simpler_incognito_mode, descrId = MR.strings.v5_3_simpler_incognito_mode_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_3_new_interface_languages, descrId = MR.strings.v5_3_new_interface_languages_descr, @@ -449,27 +494,27 @@ private val versionDescriptions: List = listOf( version = "v5.4", post = "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_desktop, titleId = MR.strings.v5_4_link_mobile_desktop, descrId = MR.strings.v5_4_link_mobile_desktop_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_group, titleId = MR.strings.v5_4_better_groups, descrId = MR.strings.v5_4_better_groups_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_theater_comedy, titleId = MR.strings.v5_4_incognito_groups, descrId = MR.strings.v5_4_incognito_groups_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_back_hand, titleId = MR.strings.v5_4_block_group_members, descrId = MR.strings.v5_4_block_group_members_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_redeem, titleId = MR.strings.v5_2_more_things, descrId = MR.strings.v5_4_more_things_descr @@ -480,28 +525,28 @@ private val versionDescriptions: List = listOf( version = "v5.5", post = "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_folder_pen, titleId = MR.strings.v5_5_private_notes, descrId = MR.strings.v5_5_private_notes_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_link, titleId = MR.strings.v5_5_simpler_connect_ui, descrId = MR.strings.v5_5_simpler_connect_ui_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_forum, titleId = MR.strings.v5_5_join_group_conversation, descrId = MR.strings.v5_5_join_group_conversation_descr, link = "simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion" ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_battery_3_bar, titleId = MR.strings.v5_5_message_delivery, descrId = MR.strings.v5_5_message_delivery_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_5_new_interface_languages, descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate, @@ -512,22 +557,22 @@ private val versionDescriptions: List = listOf( version = "v5.6", post = "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_vpn_key_filled, titleId = MR.strings.v5_6_quantum_resistant_encryption, descrId = MR.strings.v5_6_quantum_resistant_encryption_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_ios_share, titleId = MR.strings.v5_6_app_data_migration, descrId = MR.strings.v5_6_app_data_migration_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_call, titleId = MR.strings.v5_6_picture_in_picture_calls, descrId = MR.strings.v5_6_picture_in_picture_calls_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_back_hand, titleId = MR.strings.v5_6_safer_groups, descrId = MR.strings.v5_6_safer_groups_descr @@ -538,32 +583,32 @@ private val versionDescriptions: List = listOf( version = "v5.7", post = "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_vpn_key_filled, titleId = MR.strings.v5_6_quantum_resistant_encryption, descrId = MR.strings.v5_7_quantum_resistant_encryption_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_forward, titleId = MR.strings.v5_7_forward, descrId = MR.strings.v5_7_forward_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_music_note, titleId = MR.strings.v5_7_call_sounds, descrId = MR.strings.v5_7_call_sounds_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_account_box, titleId = MR.strings.v5_7_shape_profile_images, descrId = MR.strings.v5_7_shape_profile_images_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_wifi_tethering, titleId = MR.strings.v5_7_network, descrId = MR.strings.v5_7_network_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_7_new_interface_languages, descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate, @@ -574,27 +619,27 @@ private val versionDescriptions: List = listOf( version = "v5.8", post = "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_settings_ethernet, titleId = MR.strings.v5_8_private_routing, descrId = MR.strings.v5_8_private_routing_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_palette, titleId = MR.strings.v5_8_chat_themes, descrId = MR.strings.v5_8_chat_themes_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_security, titleId = MR.strings.v5_8_safe_files, descrId = MR.strings.v5_8_safe_files_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_battery_3_bar, titleId = MR.strings.v5_8_message_delivery, descrId = MR.strings.v5_8_message_delivery_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_8_persian_ui, descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate @@ -605,7 +650,7 @@ private val versionDescriptions: List = listOf( version = "v6.0", post = "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = null, titleId = MR.strings.v6_0_new_chat_experience, descrId = null, @@ -616,7 +661,7 @@ private val versionDescriptions: List = listOf( MR.images.ic_match_case to MR.strings.v6_0_increase_font_size ) ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = null, titleId = MR.strings.v6_0_new_media_options, descrId = null, @@ -625,23 +670,23 @@ private val versionDescriptions: List = listOf( MR.images.ic_blur_on to MR.strings.v6_0_privacy_blur, ) ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_toast, titleId = MR.strings.v6_0_reachable_chat_toolbar, descrId = MR.strings.v6_0_reachable_chat_toolbar_descr, show = appPlatform.isAndroid ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_settings_ethernet, titleId = MR.strings.v5_8_private_routing, descrId = MR.strings.v6_0_private_routing_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_wifi_tethering, titleId = MR.strings.v6_0_connection_servers_status, descrId = MR.strings.v6_0_connection_servers_status_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_upgrade, titleId = MR.strings.v6_0_upgrade_app, descrId = MR.strings.v6_0_upgrade_app_descr, @@ -653,18 +698,18 @@ private val versionDescriptions: List = listOf( version = "v6.1", post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_verified_user, titleId = MR.strings.v6_1_better_security, descrId = MR.strings.v6_1_better_security_descr, link = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html" ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_videocam, titleId = MR.strings.v6_1_better_calls, descrId = MR.strings.v6_1_better_calls_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = null, titleId = MR.strings.v6_1_better_user_experience, descrId = null, @@ -678,6 +723,39 @@ private val versionDescriptions: List = listOf( ), ), ), + VersionDescription( + version = "v6.2-beta.1", + post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html", + features = listOf( + VersionFeature.FeatureView( + icon = null, + titleId = MR.strings.v6_2_network_decentralization, + view = { modalManager -> + Column { + val src = (operatorsInfo[OperatorTag.Flux] ?: dummyOperatorInfo).largeLogo + Image(painterResource(src), null, modifier = Modifier.height(48.dp)) + Text(stringResource(MR.strings.v6_2_network_decentralization_descr), modifier = Modifier.padding(top = 8.dp)) + Row { + Text( + stringResource(MR.strings.v6_2_network_decentralization_enable_flux), + color = MaterialTheme.colors.primary, + modifier = Modifier.clickable { + modalManager.showModalCloseable { close -> ChooseServerOperators(onboarding = false, close, modalManager) } + } + ) + Text(" ") + Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux_reason)) + } + } + } + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_chat, + titleId = MR.strings.v6_2_improved_chat_navigation, + descrId = MR.strings.v6_2_improved_chat_navigation_descr + ), + ), + ) ) private val lastVersion = versionDescriptions.last().version @@ -700,7 +778,8 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean { @Composable fun PreviewWhatsNewView() { SimpleXTheme { - WhatsNewView( + val data = remember { ModalData() } + data.WhatsNewView( viaSettings = true, close = {} ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index e727b94781..1d01ab11ff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -35,6 +35,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.validPort import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt deleted file mode 100644 index f5e3cda2c7..0000000000 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt +++ /dev/null @@ -1,383 +0,0 @@ -package chat.simplex.common.views.usersettings - -import SectionBottomSpacer -import SectionDividerSpaced -import SectionItemView -import SectionTextFooter -import SectionView -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.* -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress -import chat.simplex.common.views.helpers.* -import chat.simplex.common.model.* -import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.platform.appPlatform -import chat.simplex.res.MR - -@Composable -fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) { - var presetServers by remember(rhId) { mutableStateOf(emptyList()) } - var servers by remember { stateGetOrPut("servers") { emptyList() } } - var serversAlreadyLoaded by remember { stateGetOrPut("serversAlreadyLoaded") { false } } - val currServers = remember(rhId) { mutableStateOf(servers) } - val testing = rememberSaveable(rhId) { mutableStateOf(false) } - val serversUnchanged = remember(servers) { derivedStateOf { servers == currServers.value || testing.value } } - val allServersDisabled = remember { derivedStateOf { servers.none { it.enabled } } } - val saveDisabled = remember(servers) { - derivedStateOf { - servers.isEmpty() || - servers == currServers.value || - testing.value || - servers.none { srv -> - val address = parseServerAddress(srv.server) - address != null && uniqueAddress(srv, address, servers) - } || - allServersDisabled.value - } - } - - KeyChangeEffect(rhId) { - servers = emptyList() - serversAlreadyLoaded = false - } - - LaunchedEffect(rhId) { - withApi { - val res = m.controller.getUserProtoServers(rhId, serverProtocol) - if (res != null) { - currServers.value = res.protoServers - presetServers = res.presetServers - if (servers.isEmpty() && !serversAlreadyLoaded) { - servers = currServers.value - serversAlreadyLoaded = true - } - } - } - } - val testServersJob = CancellableOnGoneJob() - fun showServer(server: ServerCfg) { - ModalManager.start.showModalCloseable(true) { close -> - var old by remember { mutableStateOf(server) } - val index = servers.indexOf(old) - ProtocolServerView( - m, - old, - serverProtocol, - onUpdate = { updated -> - val newServers = ArrayList(servers) - newServers.removeAt(index) - newServers.add(index, updated) - old = updated - servers = newServers - }, - onDelete = { - val newServers = ArrayList(servers) - newServers.removeAt(index) - servers = newServers - close() - }) - } - } - ModalView( - close = { - if (saveDisabled.value) close() - else showUnsavedChangesAlert({ saveServers(rhId, serverProtocol, currServers, servers, m, close) }, close) - }, - ) { - ProtocolServersLayout( - serverProtocol, - testing = testing.value, - servers = servers, - serversUnchanged = serversUnchanged.value, - saveDisabled = saveDisabled.value, - allServersDisabled = allServersDisabled.value, - m.currentUser.value, - addServer = { - AlertManager.shared.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.smp_servers_add), - buttons = { - Column { - SectionItemView({ - AlertManager.shared.hideAlert() - servers = servers + ServerCfg.empty - // No saving until something will be changed on the next screen to prevent blank servers on the list - showServer(servers.last()) - }) { - Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - if (appPlatform.isAndroid) { - SectionItemView({ - AlertManager.shared.hideAlert() - ModalManager.start.showModalCloseable { close -> - ScanProtocolServer(rhId) { - close() - servers = servers + it - } - } - } - ) { - Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - val hasAllPresets = hasAllPresets(presetServers, servers, m) - if (!hasAllPresets) { - SectionItemView({ - AlertManager.shared.hideAlert() - servers = (servers + addAllPresets(rhId, presetServers, servers, m)).sortedByDescending { it.preset } - }) { - Text(stringResource(MR.strings.smp_servers_preset_add), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - } - } - ) - }, - testServers = { - testServersJob.value = withLongRunningApi { - testServers(testing, servers, m) { - servers = it - } - } - }, - resetServers = { - servers = currServers.value - }, - saveSMPServers = { - saveServers(rhId, serverProtocol, currServers, servers, m) - }, - showServer = ::showServer, - ) - - if (testing.value) { - Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(30.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 2.5.dp - ) - } - } - } -} - -@Composable -private fun ProtocolServersLayout( - serverProtocol: ServerProtocol, - testing: Boolean, - servers: List, - serversUnchanged: Boolean, - saveDisabled: Boolean, - allServersDisabled: Boolean, - currentUser: User?, - addServer: () -> Unit, - testServers: () -> Unit, - resetServers: () -> Unit, - saveSMPServers: () -> Unit, - showServer: (ServerCfg) -> Unit, -) { - ColumnWithScrollBar { - AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.your_SMP_servers else MR.strings.your_XFTP_servers)) - - val configuredServers = servers.filter { it.preset || it.enabled } - val otherServers = servers.filter { !(it.preset || it.enabled) } - - if (configuredServers.isNotEmpty()) { - SectionView(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.smp_servers_configured else MR.strings.xftp_servers_configured).uppercase()) { - for (srv in configuredServers) { - SectionItemView({ showServer(srv) }, disabled = testing) { - ProtocolServerView(serverProtocol, srv, servers, testing) - } - } - } - SectionTextFooter( - remember(currentUser?.displayName) { - buildAnnotatedString { - append(generalGetString(MR.strings.smp_servers_per_user) + " ") - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(currentUser?.displayName ?: "") - } - append(".") - } - } - ) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) - } - - if (otherServers.isNotEmpty()) { - SectionView(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.smp_servers_other else MR.strings.xftp_servers_other).uppercase()) { - for (srv in otherServers.filter { !(it.preset || it.enabled) }) { - SectionItemView({ showServer(srv) }, disabled = testing) { - ProtocolServerView(serverProtocol, srv, servers, testing) - } - } - } - } - - SectionView { - SettingsActionItem( - painterResource(MR.images.ic_add), - stringResource(MR.strings.smp_servers_add), - addServer, - disabled = testing, - textColor = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, - iconColor = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary - ) - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) - } - - SectionView { - SectionItemView(resetServers, disabled = serversUnchanged) { - Text(stringResource(MR.strings.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) - } - val testServersDisabled = testing || allServersDisabled - SectionItemView(testServers, disabled = testServersDisabled) { - Text(stringResource(MR.strings.smp_servers_test_servers), color = if (!testServersDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) - } - SectionItemView(saveSMPServers, disabled = saveDisabled) { - Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) - } - } - SectionDividerSpaced(maxBottomPadding = false) - SectionView { - HowToButton() - } - SectionBottomSpacer() - } -} - -@Composable -private fun ProtocolServerView(serverProtocol: ServerProtocol, srv: ServerCfg, servers: List, disabled: Boolean) { - val address = parseServerAddress(srv.server) - when { - address == null || !address.valid || address.serverProtocol != serverProtocol || !uniqueAddress(srv, address, servers) -> InvalidServer() - !srv.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary) - else -> ShowTestStatus(srv) - } - Spacer(Modifier.padding(horizontal = 4.dp)) - val text = address?.hostnames?.firstOrNull() ?: srv.server - if (srv.enabled) { - Text(text, color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground, maxLines = 1) - } else { - Text(text, maxLines = 1, color = MaterialTheme.colors.secondary) - } -} - -@Composable -private fun HowToButton() { - val uriHandler = LocalUriHandler.current - SettingsActionItem( - painterResource(MR.images.ic_open_in_new), - stringResource(MR.strings.how_to_use_your_servers), - { uriHandler.openUriCatching("https://simplex.chat/docs/server.html") }, - textColor = MaterialTheme.colors.primary, - iconColor = MaterialTheme.colors.primary - ) -} - -@Composable -fun InvalidServer() { - Icon(painterResource(MR.images.ic_error), null, tint = MaterialTheme.colors.error) -} - -private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List): Boolean = servers.all { srv -> - address.hostnames.all { host -> - srv.id == s.id || !srv.server.contains(host) - } -} - -private fun hasAllPresets(presetServers: List, servers: List, m: ChatModel): Boolean = - presetServers.all { hasPreset(it, servers) } ?: true - -private fun addAllPresets(rhId: Long?, presetServers: List, servers: List, m: ChatModel): List { - val toAdd = ArrayList() - for (srv in presetServers) { - if (!hasPreset(srv, servers)) { - toAdd.add(srv) - } - } - return toAdd -} - -private fun hasPreset(srv: ServerCfg, servers: List): Boolean = - servers.any { it.server == srv.server } - -private suspend fun testServers(testing: MutableState, servers: List, m: ChatModel, onUpdated: (List) -> Unit) { - val resetStatus = resetTestStatus(servers) - onUpdated(resetStatus) - testing.value = true - val fs = runServersTest(resetStatus, m) { onUpdated(it) } - testing.value = false - if (fs.isNotEmpty()) { - val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n") - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.smp_servers_test_failed), - text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg - ) - } -} - -private fun resetTestStatus(servers: List): List { - val copy = ArrayList(servers) - for ((index, server) in servers.withIndex()) { - if (server.enabled) { - copy.removeAt(index) - copy.add(index, server.copy(tested = null)) - } - } - return copy -} - -private suspend fun runServersTest(servers: List, m: ChatModel, onUpdated: (List) -> Unit): Map { - val fs: MutableMap = mutableMapOf() - val updatedServers = ArrayList(servers) - for ((index, server) in servers.withIndex()) { - if (server.enabled) { - interruptIfCancelled() - val (updatedServer, f) = testServerConnection(server, m) - updatedServers.removeAt(index) - updatedServers.add(index, updatedServer) - // toList() is important. Otherwise, Compose will not redraw the screen after first update - onUpdated(updatedServers.toList()) - if (f != null) { - fs[serverHostname(updatedServer.server)] = f - } - } - } - return fs -} - -private fun saveServers(rhId: Long?, protocol: ServerProtocol, currServers: MutableState>, servers: List, m: ChatModel, afterSave: () -> Unit = {}) { - withBGApi { - if (m.controller.setUserProtoServers(rhId, protocol, servers)) { - currServers.value = servers - } - afterSave() - } -} - -private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { - AlertManager.shared.showAlertDialogStacked( - title = generalGetString(MR.strings.smp_save_servers_question), - confirmText = generalGetString(MR.strings.save_verb), - dismissText = generalGetString(MR.strings.exit_without_saving), - onConfirm = save, - onDismiss = revert, - ) -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 78c5e3b212..f3d22e0cdf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -25,14 +25,13 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrateFromDeviceView import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView import chat.simplex.res.MR -import kotlinx.coroutines.* @Composable fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) { @@ -102,7 +101,7 @@ fun SettingsLayout( SectionView(stringResource(MR.strings.settings_section_title_settings)) { SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) }) @@ -118,7 +117,7 @@ fun SettingsLayout( SectionView(stringResource(MR.strings.settings_section_title_help)) { SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close = close) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }) if (!chatModel.desktopNoUserNoRemote) { SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt similarity index 99% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index 5757b5d1f4..838cac0172 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -1,4 +1,4 @@ -package chat.simplex.common.views.usersettings +package chat.simplex.common.views.usersettings.networkAndServers import SectionBottomSpacer import SectionDividerSpaced @@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.usersettings.SettingsPreferenceItem import chat.simplex.res.MR import java.text.DecimalFormat diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt similarity index 52% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 2c4870b121..ef5b82a5d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -1,6 +1,7 @@ -package chat.simplex.common.views.usersettings +package chat.simplex.common.views.usersettings.networkAndServers import SectionBottomSpacer +import SectionCustomFooter import SectionDividerSpaced import SectionItemView import SectionItemWithValue @@ -20,119 +21,245 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.text.input.* import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.Image +import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatController.getServerOperators +import chat.simplex.common.model.ChatController.getUserServers +import chat.simplex.common.model.ChatController.setUserServers import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.common.views.onboarding.ReadableText +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR +import kotlinx.coroutines.launch @Composable -fun NetworkAndServersView() { +fun ModalData.NetworkAndServersView(close: () -> Unit) { val currentRemoteHost by remember { chatModel.currentRemoteHost } // It's not a state, just a one-time value. Shouldn't be used in any state-related situations val netCfg = remember { chatModel.controller.getNetCfg() } val networkUseSocksProxy: MutableState = remember { mutableStateOf(netCfg.useSocksProxy) } + val currUserServers = remember { stateGetOrPut("currUserServers") { emptyList() } } + val userServers = remember { stateGetOrPut("userServers") { emptyList() } } + val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList() } } + val scope = rememberCoroutineScope() val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } } - NetworkAndServersLayout( - currentRemoteHost = currentRemoteHost, - networkUseSocksProxy = networkUseSocksProxy, - onionHosts = remember { mutableStateOf(netCfg.onionHosts) }, - toggleSocksProxy = { enable -> - val def = NetCfg.defaults - val proxyDef = NetCfg.proxyDefaults - if (enable) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.network_enable_socks), - text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value), - confirmText = generalGetString(MR.strings.confirm_verb), - onConfirm = { - withBGApi { - var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get()) - if (conf.tcpConnectTimeout == def.tcpConnectTimeout) { - conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout) - } - if (conf.tcpTimeout == def.tcpTimeout) { - conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout) - } - if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) { - conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb) - } - if (conf.rcvConcurrency == def.rcvConcurrency) { - conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency) - } - chatModel.controller.apiSetNetworkConfig(conf) - chatModel.controller.setNetCfg(conf) - networkUseSocksProxy.value = true - } - } - ) + ModalView( + close = { + if (!serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)) { + close() } else { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.network_disable_socks), - text = generalGetString(MR.strings.network_disable_socks_info), - confirmText = generalGetString(MR.strings.confirm_verb), - onConfirm = { - withBGApi { - var conf = controller.getNetCfg().copy(socksProxy = null) - if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) { - conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout) - } - if (conf.tcpTimeout == proxyDef.tcpTimeout) { - conf = conf.copy(tcpTimeout = def.tcpTimeout) - } - if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) { - conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb) - } - if (conf.rcvConcurrency == proxyDef.rcvConcurrency) { - conf = conf.copy(rcvConcurrency = def.rcvConcurrency) - } - chatModel.controller.apiSetNetworkConfig(conf) - chatModel.controller.setNetCfg(conf) - networkUseSocksProxy.value = false - } - } + showUnsavedChangesAlert( + { scope.launch { saveServers(currentRemoteHost?.remoteHostId, currUserServers, userServers) }}, + close ) } } - ) + ) { + NetworkAndServersLayout( + currentRemoteHost = currentRemoteHost, + networkUseSocksProxy = networkUseSocksProxy, + onionHosts = remember { mutableStateOf(netCfg.onionHosts) }, + currUserServers = currUserServers, + userServers = userServers, + serverErrors = serverErrors, + toggleSocksProxy = { enable -> + val def = NetCfg.defaults + val proxyDef = NetCfg.proxyDefaults + if (enable) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.network_enable_socks), + text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value), + confirmText = generalGetString(MR.strings.confirm_verb), + onConfirm = { + withBGApi { + var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get()) + if (conf.tcpConnectTimeout == def.tcpConnectTimeout) { + conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout) + } + if (conf.tcpTimeout == def.tcpTimeout) { + conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout) + } + if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) { + conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb) + } + if (conf.rcvConcurrency == def.rcvConcurrency) { + conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency) + } + chatModel.controller.apiSetNetworkConfig(conf) + chatModel.controller.setNetCfg(conf) + networkUseSocksProxy.value = true + } + } + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.network_disable_socks), + text = generalGetString(MR.strings.network_disable_socks_info), + confirmText = generalGetString(MR.strings.confirm_verb), + onConfirm = { + withBGApi { + var conf = controller.getNetCfg().copy(socksProxy = null) + if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) { + conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout) + } + if (conf.tcpTimeout == proxyDef.tcpTimeout) { + conf = conf.copy(tcpTimeout = def.tcpTimeout) + } + if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) { + conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb) + } + if (conf.rcvConcurrency == proxyDef.rcvConcurrency) { + conf = conf.copy(rcvConcurrency = def.rcvConcurrency) + } + chatModel.controller.apiSetNetworkConfig(conf) + chatModel.controller.setNetCfg(conf) + networkUseSocksProxy.value = false + } + } + ) + } + } + ) + } } @Composable fun NetworkAndServersLayout( currentRemoteHost: RemoteHostInfo?, networkUseSocksProxy: MutableState, onionHosts: MutableState, + currUserServers: MutableState>, + serverErrors: MutableState>, + userServers: MutableState>, toggleSocksProxy: (Boolean) -> Unit, ) { val m = chatModel + val conditionsAction = remember { m.conditions.value.conditionsAction } + val anyOperatorEnabled = remember { derivedStateOf { userServers.value.any { it.operator?.enabled == true } } } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + if (currUserServers.value.isNotEmpty() || userServers.value.isNotEmpty()) { + return@LaunchedEffect + } + try { + val servers = getUserServers(rh = currentRemoteHost?.remoteHostId) + if (servers != null) { + currUserServers.value = servers + userServers.value = servers + } + } catch (e: Exception) { + Log.e(TAG, e.stackTraceToString()) + } + } + + @Composable + fun ConditionsButton(conditionsAction: UsageConditionsAction, rhId: Long?) { + SectionItemView( + click = { ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> UsageConditionsView(currUserServers, userServers, close, rhId) } }, + ) { + Text( + stringResource(if (conditionsAction is UsageConditionsAction.Review) MR.strings.operator_review_conditions else MR.strings.operator_conditions_accepted), + color = MaterialTheme.colors.primary + ) + } + } + ColumnWithScrollBar { - val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) } - val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) }} + val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) } + val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) } } AppBarTitle(stringResource(MR.strings.network_and_servers)) + // TODO: Review this and socks. if (!chatModel.desktopNoUserNoRemote) { - SectionView(generalGetString(MR.strings.settings_section_title_messages)) { - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.message_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) } }) + SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) { + userServers.value.forEachIndexed { index, srv -> + srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, currentRemoteHost?.remoteHostId) } + } + } + if (conditionsAction != null && anyOperatorEnabled.value) { + ConditionsButton(conditionsAction, rhId = currentRemoteHost?.remoteHostId) + } + val footerText = if (conditionsAction is UsageConditionsAction.Review && conditionsAction.deadline != null && anyOperatorEnabled.value) { + String.format(generalGetString(MR.strings.operator_conditions_will_be_accepted_on), localDate(conditionsAction.deadline)) + } else null - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.media_and_file_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } }) + if (footerText != null) { + SectionTextFooter(footerText) + } + SectionDividerSpaced() + } - if (currentRemoteHost == null) { - UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) - SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) }}) - SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } }) - if (networkUseSocksProxy.value) { - SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) - SectionDividerSpaced(maxTopPadding = true) - } else { - SectionDividerSpaced() + SectionView(generalGetString(MR.strings.settings_section_title_messages)) { + val nullOperatorIndex = userServers.value.indexOfFirst { it.operator == null } + + if (nullOperatorIndex != -1) { + SectionItemView({ + ModalManager.start.showModal { + YourServersView( + userServers = userServers, + serverErrors = serverErrors, + operatorIndex = nullOperatorIndex, + rhId = currentRemoteHost?.remoteHostId + ) + } + }) { + Icon( + painterResource(MR.images.ic_dns), + stringResource(MR.strings.your_servers), + tint = MaterialTheme.colors.secondary + ) + TextIconSpaced() + Text(stringResource(MR.strings.your_servers), color = MaterialTheme.colors.onBackground) + + if (currUserServers.value.getOrNull(nullOperatorIndex) != userServers.value.getOrNull(nullOperatorIndex)) { + Spacer(Modifier.weight(1f)) + UnsavedChangesIndicator() } } } + + if (currentRemoteHost == null) { + UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) + SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) } }) + SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } }) + if (networkUseSocksProxy.value) { + SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) + SectionDividerSpaced(maxTopPadding = true) + } else { + SectionDividerSpaced(maxBottomPadding = false) + } + } } + val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value) + + SectionItemView( + { scope.launch { saveServers(rhId = currentRemoteHost?.remoteHostId, currUserServers, userServers) } }, + disabled = saveDisabled, + ) { + Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) + } + val serversErr = globalServersError(serverErrors.value) + if (serversErr != null) { + SectionCustomFooter { + ServersErrorFooter(serversErr) + } + } else if (serverErrors.value.isNotEmpty()) { + SectionCustomFooter { + ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration)) + } + } + + SectionDividerSpaced() SectionView(generalGetString(MR.strings.settings_section_title_calls)) { SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } }) @@ -504,6 +631,165 @@ fun showWrongProxyConfigAlert() { ) } +@Composable() +private fun ServerOperatorRow( + index: Int, + operator: ServerOperator, + currUserServers: MutableState>, + userServers: MutableState>, + serverErrors: MutableState>, + rhId: Long? +) { + SectionItemView( + { + ModalManager.start.showModalCloseable { close -> + OperatorView( + currUserServers, + userServers, + serverErrors, + index, + rhId + ) + } + } + ) { + Image( + painterResource(operator.logo), + operator.tradeName, + modifier = Modifier.size(24.dp), + colorFilter = if (operator.enabled) null else ColorFilter.colorMatrix(ColorMatrix().apply { + setToSaturation(0f) + }) + ) + TextIconSpaced() + Text(operator.tradeName, color = if (operator.enabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) + + if (currUserServers.value.getOrNull(index) != userServers.value.getOrNull(index)) { + Spacer(Modifier.weight(1f)) + UnsavedChangesIndicator() + } + } +} + +@Composable +private fun UnsavedChangesIndicator() { + Icon( + painterResource(MR.images.ic_edit_filled), + stringResource(MR.strings.icon_descr_edited), + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(16.dp) + ) +} + +@Composable +fun UsageConditionsView( + currUserServers: MutableState>, + userServers: MutableState>, + close: () -> Unit, + rhId: Long? +) { + suspend fun acceptForOperators(rhId: Long?, operatorIds: List, close: () -> Unit) { + try { + val conditionsId = chatModel.conditions.value.currentConditions.conditionsId + val r = chatController.acceptConditions(rhId, conditionsId, operatorIds) ?: return + chatModel.conditions.value = r + updateOperatorsConditionsAcceptance(currUserServers, r.serverOperators) + updateOperatorsConditionsAcceptance(userServers, r.serverOperators) + close() + } catch (ex: Exception) { + Log.e(TAG, ex.stackTraceToString()) + } + } + + @Composable + fun AcceptConditionsButton(operatorIds: List, close: () -> Unit, bottomPadding: Dp = DEFAULT_PADDING * 2) { + val scope = rememberCoroutineScope() + Column(Modifier.fillMaxWidth().padding(bottom = bottomPadding), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingActionButton( + labelId = MR.strings.accept_conditions, + onboarding = null, + enabled = operatorIds.isNotEmpty(), + onclick = { + scope.launch { + acceptForOperators(rhId, operatorIds, close) + } + } + ) + } + } + + ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false) + when (val conditionsAction = chatModel.conditions.value.conditionsAction) { + is UsageConditionsAction.Review -> { + if (conditionsAction.operators.isNotEmpty()) { + ReadableText(MR.strings.operators_conditions_will_be_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ }) + } + Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(rhId) + } + AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, if (conditionsAction.deadline != null) DEFAULT_PADDING_HALF else DEFAULT_PADDING * 2) + if (conditionsAction.deadline != null) { + SectionTextFooter( + text = AnnotatedString(String.format(generalGetString(MR.strings.operator_conditions_accepted_for_enabled_operators_on), localDate(conditionsAction.deadline))), + textAlign = TextAlign.Center + ) + Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING)) + } + } + + is UsageConditionsAction.Accepted -> { + if (conditionsAction.operators.isNotEmpty()) { + ReadableText(MR.strings.operators_conditions_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ }) + } + Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(rhId) + } + } + + else -> { + Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(rhId) + } + } + } + } +} + +@Composable +fun ServersErrorFooter(errStr: String) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_error), + contentDescription = stringResource(MR.strings.server_error), + tint = Color.Red, + modifier = Modifier + .size(19.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) + TextIconSpaced() + Text( + errStr, + color = MaterialTheme.colors.secondary, + lineHeight = 18.sp, + fontSize = 14.sp + ) + } +} + +private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(MR.strings.smp_save_servers_question), + confirmText = generalGetString(MR.strings.save_verb), + dismissText = generalGetString(MR.strings.exit_without_saving), + onConfirm = save, + onDismiss = revert, + ) +} + fun showUpdateNetworkSettingsDialog( title: String, startsWith: String = "", @@ -521,6 +807,107 @@ fun showUpdateNetworkSettingsDialog( ) } +fun updateOperatorsConditionsAcceptance(usvs: MutableState>, updatedOperators: List) { + val modified = ArrayList(usvs.value) + for (i in modified.indices) { + val updatedOperator = updatedOperators.firstOrNull { it.operatorId == modified[i].operator?.operatorId } ?: continue + modified[i] = modified[i].copy(operator = modified[i].operator?.copy(conditionsAcceptance = updatedOperator.conditionsAcceptance)) + } + usvs.value = modified +} + +suspend fun validateServers_( + rhId: Long?, + userServersToValidate: List, + serverErrors: MutableState> +) { + try { + val errors = chatController.validateServers(rhId, userServersToValidate) ?: return + serverErrors.value = errors + } catch (ex: Exception) { + Log.e(TAG, ex.stackTraceToString()) + } +} + +fun serversCanBeSaved( + currUserServers: List, + userServers: List, + serverErrors: List +): Boolean { + return userServers != currUserServers && serverErrors.isEmpty() +} + +fun globalServersError(serverErrors: List): String? { + for (err in serverErrors) { + if (err.globalError != null) { + return err.globalError + } + } + return null +} + +fun globalSMPServersError(serverErrors: List): String? { + for (err in serverErrors) { + if (err.globalSMPError != null) { + return err.globalSMPError + } + } + return null +} + +fun globalXFTPServersError(serverErrors: List): String? { + for (err in serverErrors) { + if (err.globalXFTPError != null) { + return err.globalXFTPError + } + } + return null +} + +fun findDuplicateHosts(serverErrors: List): Set { + val duplicateHostsList = serverErrors.mapNotNull { err -> + if (err is UserServersError.DuplicateServer) { + err.duplicateHost + } else { + null + } + } + return duplicateHostsList.toSet() +} + +private suspend fun saveServers( + rhId: Long?, + currUserServers: MutableState>, + userServers: MutableState> +) { + val userServersToSave = userServers.value + try { + val set = setUserServers(rhId, userServersToSave) + + if (set) { + // Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers) + val updatedServers = getUserServers(rhId) + // Get updated operators to update model + val updatedOperators = getServerOperators(rhId) + + if (updatedOperators != null) { + chatModel.conditions.value = updatedOperators + } + + if (updatedServers != null ) { + currUserServers.value = updatedServers + userServers.value = updatedServers + } else { + currUserServers.value = userServersToSave + } + } else { + currUserServers.value = userServersToSave + } + } catch (ex: Exception) { + Log.e(TAG, ex.stackTraceToString()) + } +} + @Preview @Composable fun PreviewNetworkAndServersLayout() { @@ -530,6 +917,9 @@ fun PreviewNetworkAndServersLayout() { networkUseSocksProxy = remember { mutableStateOf(true) }, onionHosts = remember { mutableStateOf(OnionHosts.PREFER) }, toggleSocksProxy = {}, + currUserServers = remember { mutableStateOf(emptyList()) }, + userServers = remember { mutableStateOf(emptyList()) }, + serverErrors = remember { mutableStateOf(emptyList()) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt new file mode 100644 index 0000000000..1ec2534ab1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -0,0 +1,144 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import SectionBottomSpacer +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.* +import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.views.helpers.* +import chat.simplex.common.platform.* +import chat.simplex.res.MR +import kotlinx.coroutines.* + +@Composable +fun ModalData.NewServerView( + userServers: MutableState>, + serverErrors: MutableState>, + rhId: Long?, + close: () -> Unit +) { + val testing = remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val newServer = remember { mutableStateOf(UserServer.empty) } + + ModalView(close = { + addServer( + scope, + newServer.value, + userServers, + serverErrors, + rhId, + close = close + ) + }) { + Box { + NewServerLayout( + newServer, + testing.value, + testServer = { + testing.value = true + withLongRunningApi { + val res = testServerConnection(newServer.value, chatModel) + if (isActive) { + newServer.value = res.first + testing.value = false + } + } + }, + ) + + if (testing.value) { + DefaultProgressView(null) + } + } + } +} + +@Composable +private fun NewServerLayout( + server: MutableState, + testing: Boolean, + testServer: () -> Unit, +) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.smp_servers_new_server)) + CustomServer(server, testing, testServer, onDelete = null) + SectionBottomSpacer() + } +} + +fun serverProtocolAndOperator( + server: UserServer, + userServers: List +): Pair? { + val serverAddress = parseServerAddress(server.server) + return if (serverAddress != null) { + val serverProtocol = serverAddress.serverProtocol + val hostnames = serverAddress.hostnames + val matchingOperator = userServers.mapNotNull { it.operator }.firstOrNull { op -> + op.serverDomains.any { domain -> + hostnames.any { hostname -> + hostname.endsWith(domain) + } + } + } + Pair(serverProtocol, matchingOperator) + } else { + null + } +} + +fun addServer( + scope: CoroutineScope, + server: UserServer, + userServers: MutableState>, + serverErrors: MutableState>, + rhId: Long?, + close: () -> Unit +) { + val result = serverProtocolAndOperator(server, userServers.value) + if (result != null) { + val (serverProtocol, matchingOperator) = result + val operatorIndex = userServers.value.indexOfFirst { it.operator?.operatorId == matchingOperator?.operatorId } + if (operatorIndex != -1) { + // Create a mutable copy of the userServers list + val updatedUserServers = userServers.value.toMutableList() + val operatorServers = updatedUserServers[operatorIndex] + // Create a mutable copy of the smpServers or xftpServers and add the server + when (serverProtocol) { + ServerProtocol.SMP -> { + val updatedSMPServers = operatorServers.smpServers.toMutableList() + updatedSMPServers.add(server) + updatedUserServers[operatorIndex] = operatorServers.copy(smpServers = updatedSMPServers) + } + + ServerProtocol.XFTP -> { + val updatedXFTPServers = operatorServers.xftpServers.toMutableList() + updatedXFTPServers.add(server) + updatedUserServers[operatorIndex] = operatorServers.copy(xftpServers = updatedXFTPServers) + } + } + + userServers.value = updatedUserServers + close() + matchingOperator?.let { op -> + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.operator_server_alert_title), + text = String.format(generalGetString(MR.strings.server_added_to_operator__name), op.tradeName) + ) + } + } else { // Shouldn't happen + close() + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.error_adding_server)) + } + } else { + close() + if (server.server.trim().isNotEmpty()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.smp_servers_invalid_address), + text = generalGetString(MR.strings.smp_servers_check_address) + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt new file mode 100644 index 0000000000..c61a9f5ef7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -0,0 +1,708 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import SectionBottomSpacer +import SectionCustomFooter +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.* +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatController.getUsageConditions +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.net.URI + +@Composable +fun ModalData.OperatorView( + currUserServers: MutableState>, + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + rhId: Long? +) { + val testing = remember { mutableStateOf(false) } + val operator = remember { userServers.value[operatorIndex].operator_ } + val currentUser = remember { chatModel.currentUser }.value + + LaunchedEffect(userServers) { + snapshotFlow { userServers.value } + .collect { updatedServers -> + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + } + } + + Box { + ColumnWithScrollBar(Modifier.alpha(if (testing.value) 0.6f else 1f)) { + AppBarTitle(String.format(stringResource(MR.strings.operator_servers_title), operator.tradeName)) + OperatorViewLayout( + currUserServers, + userServers, + serverErrors, + operatorIndex, + navigateToProtocolView = { serverIndex, server, protocol -> + navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + }, + currentUser, + rhId, + testing + ) + } + + if (testing.value) { + DefaultProgressView(null) + } + } +} + +fun navigateToProtocolView( + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + rhId: Long?, + serverIndex: Int, + server: UserServer, + protocol: ServerProtocol +) { + ModalManager.start.showCustomModal { close -> + ProtocolServerView( + m = chatModel, + server = server, + serverProtocol = protocol, + userServers = userServers, + serverErrors = serverErrors, + onDelete = { + if (protocol == ServerProtocol.SMP) { + deleteSMPServer(userServers, operatorIndex, serverIndex) + } else { + deleteXFTPServer(userServers, operatorIndex, serverIndex) + } + close() + }, + onUpdate = { updatedServer -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = if (protocol == ServerProtocol.SMP) { + this[operatorIndex].smpServers.toMutableList().apply { + this[serverIndex] = updatedServer + } + } else this[operatorIndex].smpServers, + xftpServers = if (protocol == ServerProtocol.XFTP) { + this[operatorIndex].xftpServers.toMutableList().apply { + this[serverIndex] = updatedServer + } + } else this[operatorIndex].xftpServers + ) + } + }, + close = close, + rhId = rhId + ) + } +} + +@Composable +fun OperatorViewLayout( + currUserServers: MutableState>, + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, + currentUser: User?, + rhId: Long?, + testing: MutableState +) { + val operator by remember { derivedStateOf { userServers.value[operatorIndex].operator_ } } + val scope = rememberCoroutineScope() + val duplicateHosts = findDuplicateHosts(serverErrors.value) + + Column { + SectionView(generalGetString(MR.strings.operator).uppercase()) { + SectionItemView({ ModalManager.start.showModalCloseable { _ -> OperatorInfoView(operator) } }) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image(painterResource(operator.largeLogo), null, Modifier.height(48.dp)) + Spacer(Modifier.fillMaxWidth().weight(1f)) + Box(Modifier.padding(horizontal = 2.dp)) { + Icon(painterResource(MR.images.ic_info), null, Modifier.size(24.dp), tint = MaterialTheme.colors.primaryVariant) + } + } + } + UseOperatorToggle( + currUserServers = currUserServers, + userServers = userServers, + serverErrors = serverErrors, + operatorIndex = operatorIndex, + rhId = rhId + ) + } + val serversErr = globalServersError(serverErrors.value) + if (serversErr != null) { + SectionCustomFooter { + ServersErrorFooter(serversErr) + } + } else { + val footerText = when (val c = operator.conditionsAcceptance) { + is ConditionsAcceptance.Accepted -> if (c.acceptedAt != null) { + String.format(generalGetString(MR.strings.operator_conditions_accepted_on), localDate(c.acceptedAt)) + } else null + is ConditionsAcceptance.Required -> if (operator.enabled && c.deadline != null) { + String.format(generalGetString(MR.strings.operator_conditions_will_be_accepted_on), localDate(c.deadline)) + } else null + } + if (footerText != null) { + SectionTextFooter(footerText) + } + } + + if (operator.enabled) { + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.operator_use_for_messages).uppercase()) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + stringResource(MR.strings.operator_use_for_messages_receiving), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = userServers.value[operatorIndex].operator_.smpRoles.storage, + onCheckedChange = { enabled -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + operator = this[operatorIndex].operator?.copy( + smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false) + ) + ) + } + } + ) + } + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + stringResource(MR.strings.operator_use_for_messages_private_routing), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = userServers.value[operatorIndex].operator_.smpRoles.proxy, + onCheckedChange = { enabled -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + operator = this[operatorIndex].operator?.copy( + smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled) + ) + ) + } + } + ) + } + + } + val smpErr = globalSMPServersError(serverErrors.value) + if (smpErr != null) { + SectionCustomFooter { + ServersErrorFooter(smpErr) + } + } + } + + // Preset servers can't be deleted + if (userServers.value[operatorIndex].smpServers.any { it.preset }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.message_servers).uppercase()) { + userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> + if (!server.preset) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.SMP, + duplicateHosts = duplicateHosts + ) + } + } + } + val smpErr = globalSMPServersError(serverErrors.value) + if (smpErr != null) { + SectionCustomFooter { + ServersErrorFooter(smpErr) + } + } else { + SectionTextFooter( + remember(currentUser?.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.smp_servers_per_user) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser?.displayName ?: "") + } + append(".") + } + } + ) + } + } + + if (userServers.value[operatorIndex].smpServers.any { !it.preset && !it.deleted }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.operator_added_message_servers).uppercase()) { + userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> + if (server.deleted || server.preset) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.SMP, + duplicateHosts = duplicateHosts + ) + } + } + } + } + + if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.operator_use_for_files).uppercase()) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + stringResource(MR.strings.operator_use_for_sending), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = userServers.value[operatorIndex].operator_.xftpRoles.storage, + onCheckedChange = { enabled -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + operator = this[operatorIndex].operator?.copy( + xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false) + ) + ) + } + } + ) + } + } + val xftpErr = globalXFTPServersError(serverErrors.value) + if (xftpErr != null) { + SectionCustomFooter { + ServersErrorFooter(xftpErr) + } + } + } + + // Preset servers can't be deleted + if (userServers.value[operatorIndex].xftpServers.any { it.preset }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) { + userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> + if (!server.preset) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.XFTP, + duplicateHosts = duplicateHosts + ) + } + } + } + val xftpErr = globalXFTPServersError(serverErrors.value) + if (xftpErr != null) { + SectionCustomFooter { + ServersErrorFooter(xftpErr) + } + } else { + SectionTextFooter( + remember(currentUser?.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.xftp_servers_per_user) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser?.displayName ?: "") + } + append(".") + } + } + ) + } + } + + if (userServers.value[operatorIndex].xftpServers.any { !it.preset && !it.deleted}) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.operator_added_xftp_servers).uppercase()) { + userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> + if (server.deleted || server.preset) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.XFTP, + duplicateHosts = duplicateHosts + ) + } + } + } + } + + SectionDividerSpaced() + SectionView { + TestServersButton( + testing = testing, + smpServers = userServers.value[operatorIndex].smpServers, + xftpServers = userServers.value[operatorIndex].xftpServers, + ) { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } + + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + } + } + + SectionBottomSpacer() + } + } +} + +@Composable +private fun OperatorInfoView(serverOperator: ServerOperator) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.operator_info_title)) + + SectionView { + SectionItemView { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Image(painterResource(serverOperator.largeLogo), null, Modifier.height(48.dp)) + if (serverOperator.legalName != null) { + Text(serverOperator.legalName) + } + } + } + } + + SectionDividerSpaced(maxBottomPadding = false) + + SectionView { + SectionItemView { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + serverOperator.info.description.forEach { d -> + Text(d) + } + } + } + } + + SectionDividerSpaced() + + SectionView(generalGetString(MR.strings.operator_website).uppercase()) { + SectionItemView { + val website = serverOperator.info.website + val uriHandler = LocalUriHandler.current + Text(website, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(website) }) + } + } + } +} + +@Composable +private fun UseOperatorToggle( + currUserServers: MutableState>, + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + rhId: Long? +) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + stringResource(MR.strings.operator_use_operator_toggle_description), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = userServers.value[operatorIndex].operator?.enabled ?: false, + onCheckedChange = { enabled -> + val operator = userServers.value[operatorIndex].operator + if (enabled) { + when (val conditionsAcceptance = operator?.conditionsAcceptance) { + is ConditionsAcceptance.Accepted -> { + changeOperatorEnabled(userServers, operatorIndex, true) + } + + is ConditionsAcceptance.Required -> { + if (conditionsAcceptance.deadline == null) { + ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + SingleOperatorUsageConditionsView( + currUserServers = currUserServers, + userServers = userServers, + serverErrors = serverErrors, + operatorIndex = operatorIndex, + rhId = rhId, + close = close + ) + } + } else { + changeOperatorEnabled(userServers, operatorIndex, true) + } + } + + else -> {} + } + } else { + changeOperatorEnabled(userServers, operatorIndex, false) + } + }, + ) + } +} + +@Composable +private fun SingleOperatorUsageConditionsView( + currUserServers: MutableState>, + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + rhId: Long?, + close: () -> Unit +) { + val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } } + val operator = remember { userServers.value[operatorIndex].operator_ } + val scope = rememberCoroutineScope() + + suspend fun acceptForOperators(rhId: Long?, operatorIds: List, operatorIndexToEnable: Int, close: () -> Unit) { + try { + val conditionsId = chatModel.conditions.value.currentConditions.conditionsId + val r = chatController.acceptConditions(rhId, conditionsId, operatorIds) ?: return + + chatModel.conditions.value = r + updateOperatorsConditionsAcceptance(currUserServers, r.serverOperators) + updateOperatorsConditionsAcceptance(userServers, r.serverOperators) + changeOperatorEnabled(userServers, operatorIndex, true) + close() + } catch (ex: Exception) { + Log.e(TAG, ex.stackTraceToString()) + } + } + + @Composable + fun AcceptConditionsButton(close: () -> Unit) { + // Opened operator or Other enabled operators with conditions not accepted + val operatorIds = chatModel.conditions.value.serverOperators + .filter { it.operatorId == operator.id || (it.enabled && !it.conditionsAcceptance.conditionsAccepted) } + .map { it.operatorId } + + Column(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING * 2), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingActionButton( + labelId = MR.strings.accept_conditions, + onboarding = null, + enabled = operatorIds.isNotEmpty(), + onclick = { + scope.launch { + acceptForOperators(rhId, operatorIds, operatorIndex, close) + } + } + ) + } + } + + @Composable + fun UsageConditionsDestinationView(close: () -> Unit) { + ColumnWithScrollBar(modifier = Modifier.fillMaxSize()) { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false) + Column(modifier = Modifier.weight(1f).padding(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(rhId) + } + } + } + + @Composable + fun UsageConditionsNavLinkButton() { + Text( + stringResource(MR.strings.view_conditions), + color = MaterialTheme.colors.primary, + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF).clickable { + ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + UsageConditionsDestinationView(close) + } + } + ) + } + + ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(String.format(stringResource(MR.strings.use_servers_of_operator_x), operator.tradeName), enableAlphaChanges = false, withPadding = false) + if (operator.conditionsAcceptance is ConditionsAcceptance.Accepted) { + // In current UI implementation this branch doesn't get shown - as conditions can't be opened from inside operator once accepted + Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF, bottom = DEFAULT_PADDING)) { + ConditionsTextView(rhId) + } + } else if (operatorsWithConditionsAccepted.isNotEmpty()) { + ReadableText( + MR.strings.operator_conditions_accepted_for_some, + args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ } + ) + ReadableText( + MR.strings.operator_same_conditions_will_be_applied, + args = operator.legalName_ + ) + ConditionsAppliedToOtherOperatorsText(userServers = userServers.value, operatorIndex = operatorIndex) + + UsageConditionsNavLinkButton() + Spacer(Modifier.fillMaxWidth().weight(1f)) + AcceptConditionsButton(close) + } else { + ReadableText( + MR.strings.operator_in_order_to_use_accept_conditions, + args = operator.legalName_ + ) + ConditionsAppliedToOtherOperatorsText(userServers = userServers.value, operatorIndex = operatorIndex) + Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF, bottom = DEFAULT_PADDING)) { + ConditionsTextView(rhId) + } + AcceptConditionsButton(close) + } + } +} + +@Composable +fun ConditionsTextView( + rhId: Long? +) { + val conditionsData = remember { mutableStateOf?>(null) } + val failedToLoad = remember { mutableStateOf(false) } + val defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md" + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + scope.launch { + try { + val conditions = getUsageConditions(rh = rhId) + + if (conditions != null) { + conditionsData.value = conditions + } else { + failedToLoad.value = true + } + } catch (ex: Exception) { + failedToLoad.value = true + } + } + } + val conditions = conditionsData.value + + if (conditions != null) { + val (usageConditions, conditionsText, _) = conditions + + if (conditionsText != null) { + val scrollState = rememberScrollState() + Box( + modifier = Modifier + .fillMaxSize() + .border(border = BorderStroke(1.dp, CurrentColors.value.colors.secondary.copy(alpha = 0.6f)), shape = RoundedCornerShape(12.dp)) + .verticalScroll(scrollState) + .padding(8.dp) + ) { + Text( + text = conditionsText.trimIndent(), + modifier = Modifier.padding(8.dp) + ) + } + } else { + val conditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/${usageConditions.conditionsCommit}/PRIVACY.md" + ConditionsLinkView(conditionsLink) + } + } else if (failedToLoad.value) { + ConditionsLinkView(defaultConditionsLink) + } else { + DefaultProgressView(null) + } +} + +@Composable +private fun ConditionsLinkView(conditionsLink: String) { + SectionItemView { + val uriHandler = LocalUriHandler.current + Text(stringResource(MR.strings.operator_conditions_failed_to_load), color = MaterialTheme.colors.onBackground) + Text(conditionsLink, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(conditionsLink) }) + } +} + +@Composable +private fun ConditionsAppliedToOtherOperatorsText(userServers: List, operatorIndex: Int) { + val otherOperatorsToApply = remember { + derivedStateOf { + chatModel.conditions.value.serverOperators.filter { + it.enabled && + !it.conditionsAcceptance.conditionsAccepted && + it.operatorId != userServers[operatorIndex].operator_.operatorId + } + } + } + + if (otherOperatorsToApply.value.isNotEmpty()) { + ReadableText(MR.strings.operator_conditions_will_be_applied) + } +} + +@Composable +fun ConditionsLinkButton() { + val showMenu = remember { mutableStateOf(false) } + val uriHandler = LocalUriHandler.current + val oneHandUI = remember { appPrefs.oneHandUI.state } + Column { + DefaultDropdownMenu(showMenu, offset = if (oneHandUI.value) DpOffset(0.dp, -AppBarHeight * fontSizeSqrtMultiplier * 3) else DpOffset.Zero) { + val commit = chatModel.conditions.value.currentConditions.conditionsCommit + ItemAction(stringResource(MR.strings.operator_open_conditions), painterResource(MR.images.ic_draft), onClick = { + val mdUrl = "https://github.com/simplex-chat/simplex-chat/blob/$commit/PRIVACY.md" + uriHandler.openUriCatching(mdUrl) + showMenu.value = false + }) + ItemAction(stringResource(MR.strings.operator_open_changes), painterResource(MR.images.ic_more_horiz), onClick = { + val commitUrl = "https://github.com/simplex-chat/simplex-chat/commit/$commit" + uriHandler.openUriCatching(commitUrl) + showMenu.value = false + }) + } + IconButton({ showMenu.value = true }) { + Icon(painterResource(MR.images.ic_outbound), null, tint = MaterialTheme.colors.primary) + } + } +} + +private fun changeOperatorEnabled(userServers: MutableState>, operatorIndex: Int, enabled: Boolean) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + operator = this[operatorIndex].operator?.copy(enabled = enabled) + ) + } +} \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt similarity index 51% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index be566e6c5a..bebc96a28c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -1,16 +1,14 @@ -package chat.simplex.common.views.usersettings +package chat.simplex.common.views.usersettings.networkAndServers import SectionBottomSpacer import SectionDividerSpaced import SectionItemView import SectionItemViewSpaceBetween import SectionView -import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource @@ -26,62 +24,103 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* +import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged @Composable -fun ProtocolServerView(m: ChatModel, server: ServerCfg, serverProtocol: ServerProtocol, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) { - var testing by remember { mutableStateOf(false) } - ProtocolServerLayout( - testing, - server, - serverProtocol, - testServer = { - testing = true - withLongRunningApi { - val res = testServerConnection(server, m) - if (isActive) { - onUpdate(res.first) - testing = false +fun ProtocolServerView( + m: ChatModel, + server: UserServer, + serverProtocol: ServerProtocol, + userServers: MutableState>, + serverErrors: MutableState>, + onDelete: () -> Unit, + onUpdate: (UserServer) -> Unit, + close: () -> Unit, + rhId: Long? +) { + val testing = remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val draftServer = remember { mutableStateOf(server) } + + ModalView( + close = { + scope.launch { + val draftResult = serverProtocolAndOperator(draftServer.value, userServers.value) + val savedResult = serverProtocolAndOperator(server, userServers.value) + + if (draftResult != null && savedResult != null) { + val (serverToEditProtocol, serverToEditOperator) = draftResult + val (svProtocol, serverOperator) = savedResult + + if (serverToEditProtocol != svProtocol) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_updating_server_title), + text = generalGetString(MR.strings.error_server_protocol_changed) + ) + } else if (serverToEditOperator != serverOperator) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_updating_server_title), + text = generalGetString(MR.strings.error_server_operator_changed) + ) + } else { + onUpdate(draftServer.value) + close() + } + } else { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.smp_servers_invalid_address), + text = generalGetString(MR.strings.smp_servers_check_address) + ) } } - }, - onUpdate, - onDelete - ) - if (testing) { - Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(30.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 2.5.dp + } + ) { + Box { + ProtocolServerLayout( + draftServer, + serverProtocol, + testing.value, + testServer = { + testing.value = true + withLongRunningApi { + val res = testServerConnection(draftServer.value, m) + if (isActive) { + draftServer.value = res.first + testing.value = false + } + } + }, + onDelete ) + + if (testing.value) { + DefaultProgressView(null) + } } } } @Composable private fun ProtocolServerLayout( - testing: Boolean, - server: ServerCfg, + server: MutableState, serverProtocol: ServerProtocol, + testing: Boolean, testServer: () -> Unit, - onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit, ) { ColumnWithScrollBar { - AppBarTitle(stringResource(if (server.preset) MR.strings.smp_servers_preset_server else MR.strings.smp_servers_your_server)) + AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.XFTP) MR.strings.xftp_server else MR.strings.smp_server)) - if (server.preset) { - PresetServer(testing, server, testServer, onUpdate, onDelete) + if (server.value.preset) { + PresetServer(server, testing, testServer) } else { - CustomServer(testing, server, serverProtocol, testServer, onUpdate, onDelete) + CustomServer(server, testing, testServer, onDelete) } SectionBottomSpacer() } @@ -89,16 +128,14 @@ private fun ProtocolServerLayout( @Composable private fun PresetServer( + server: MutableState, testing: Boolean, - server: ServerCfg, - testServer: () -> Unit, - onUpdate: (ServerCfg) -> Unit, - onDelete: () -> Unit, + testServer: () -> Unit ) { SectionView(stringResource(MR.strings.smp_servers_preset_address).uppercase()) { SelectionContainer { Text( - server.server, + server.value.server, Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), style = TextStyle( fontFamily = FontFamily.Monospace, fontSize = 16.sp, @@ -108,23 +145,21 @@ private fun PresetServer( } } SectionDividerSpaced() - UseServerSection(true, testing, server, testServer, onUpdate, onDelete) + UseServerSection(server, true, testing, testServer) } @Composable -private fun CustomServer( +fun CustomServer( + server: MutableState, testing: Boolean, - server: ServerCfg, - serverProtocol: ServerProtocol, testServer: () -> Unit, - onUpdate: (ServerCfg) -> Unit, - onDelete: () -> Unit, + onDelete: (() -> Unit)?, ) { - val serverAddress = remember { mutableStateOf(server.server) } + val serverAddress = remember { mutableStateOf(server.value.server) } val valid = remember { derivedStateOf { with(parseServerAddress(serverAddress.value)) { - this?.valid == true && this.serverProtocol == serverProtocol + this?.valid == true } } } @@ -142,13 +177,14 @@ private fun CustomServer( snapshotFlow { serverAddress.value } .distinctUntilChanged() .collect { - testedPreviously[server.server] = server.tested - onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value])) + testedPreviously[server.value.server] = server.value.tested + server.value = server.value.copy(server = it, tested = testedPreviously[serverAddress.value]) } } } SectionDividerSpaced(maxTopPadding = true) - UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete) + + UseServerSection(server, valid.value, testing, testServer, onDelete) if (valid.value) { SectionDividerSpaced() @@ -160,43 +196,44 @@ private fun CustomServer( @Composable private fun UseServerSection( + server: MutableState, valid: Boolean, testing: Boolean, - server: ServerCfg, testServer: () -> Unit, - onUpdate: (ServerCfg) -> Unit, - onDelete: () -> Unit, + onDelete: (() -> Unit)? = null, ) { SectionView(stringResource(MR.strings.smp_servers_use_server).uppercase()) { SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) { Text(stringResource(MR.strings.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) - ShowTestStatus(server) + ShowTestStatus(server.value) } - val enabled = rememberUpdatedState(server.enabled) + val enabled = rememberUpdatedState(server.value.enabled) PreferenceToggle( stringResource(MR.strings.smp_servers_use_server_for_new_conn), - disabled = server.tested != true && !server.preset, + disabled = testing, checked = enabled.value ) { - onUpdate(server.copy(enabled = it)) + server.value = server.value.copy(enabled = it) } - - SectionItemView(onDelete, disabled = testing) { - Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error) + + if (onDelete != null) { + SectionItemView(onDelete, disabled = testing) { + Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error) + } } } } @Composable -fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) = +fun ShowTestStatus(server: UserServer, modifier: Modifier = Modifier) = when (server.tested) { true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen) false -> Icon(painterResource(MR.images.ic_close), null, modifier, tint = MaterialTheme.colors.error) else -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = Color.Transparent) } -suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair = +suspend fun testServerConnection(server: UserServer, m: ChatModel): Pair = try { val r = m.controller.testProtoServer(server.remoteHostId, server.server) server.copy(tested = r == null) to r diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt new file mode 100644 index 0000000000..63bf8b1dc4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -0,0 +1,407 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import SectionBottomSpacer +import SectionCustomFooter +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.res.MR +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun ModalData.YourServersView( + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + rhId: Long? +) { + val testing = remember { mutableStateOf(false) } + val currentUser = remember { chatModel.currentUser }.value + val scope = rememberCoroutineScope() + + LaunchedEffect(userServers) { + snapshotFlow { userServers.value } + .collect { updatedServers -> + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + } + } + + Box { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.your_servers)) + YourServersViewLayout( + scope, + userServers, + serverErrors, + operatorIndex, + navigateToProtocolView = { serverIndex, server, protocol -> + navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + }, + currentUser, + rhId, + testing + ) + } + + if (testing.value) { + DefaultProgressView(null) + } + } +} + +@Composable +fun YourServersViewLayout( + scope: CoroutineScope, + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, + currentUser: User?, + rhId: Long?, + testing: MutableState +) { + val duplicateHosts = findDuplicateHosts(serverErrors.value) + + Column { + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { + SectionView(generalGetString(MR.strings.message_servers).uppercase()) { + userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> + if (server.deleted) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.SMP, + duplicateHosts = duplicateHosts + ) + } + } + } + val smpErr = globalSMPServersError(serverErrors.value) + if (smpErr != null) { + SectionCustomFooter { + ServersErrorFooter(smpErr) + } + } else { + SectionTextFooter( + remember(currentUser?.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.smp_servers_per_user) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser?.displayName ?: "") + } + append(".") + } + } + ) + } + } + + if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) { + userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> + if (server.deleted) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.XFTP, + duplicateHosts = duplicateHosts + ) + } + } + } + val xftpErr = globalXFTPServersError(serverErrors.value) + if (xftpErr != null) { + SectionCustomFooter { + ServersErrorFooter(xftpErr) + } + } else { + SectionTextFooter( + remember(currentUser?.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.xftp_servers_per_user) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser?.displayName ?: "") + } + append(".") + } + } + ) + } + } + + if ( + userServers.value[operatorIndex].smpServers.any { !it.deleted } || + userServers.value[operatorIndex].xftpServers.any { !it.deleted } + ) { + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + } + + SectionView { + SettingsActionItem( + painterResource(MR.images.ic_add), + stringResource(MR.strings.smp_servers_add), + click = { showAddServerDialog(scope, userServers, serverErrors, rhId) }, + disabled = testing.value, + textColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + val serversErr = globalServersError(serverErrors.value) + if (serversErr != null) { + SectionCustomFooter { + ServersErrorFooter(serversErr) + } + } + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + + SectionView { + TestServersButton( + testing = testing, + smpServers = userServers.value[operatorIndex].smpServers, + xftpServers = userServers.value[operatorIndex].xftpServers, + ) { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } + + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + } + + HowToButton() + } + SectionBottomSpacer() + } +} + +@Composable +fun TestServersButton( + smpServers: List, + xftpServers: List, + testing: MutableState, + onUpdate: (ServerProtocol, List) -> Unit +) { + val scope = rememberCoroutineScope() + val disabled = derivedStateOf { (smpServers.none { it.enabled } && xftpServers.none { it.enabled }) || testing.value } + + SectionItemView( + { + scope.launch { + testServers(testing, smpServers, xftpServers, chatModel, onUpdate) + } + }, + disabled = disabled.value + ) { + Text(stringResource(MR.strings.smp_servers_test_servers), color = if (!disabled.value) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) + } +} + +fun showAddServerDialog( + scope: CoroutineScope, + userServers: MutableState>, + serverErrors: MutableState>, + rhId: Long? +) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.smp_servers_add), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + ModalManager.start.showCustomModal { close -> + NewServerView(userServers, serverErrors, rhId, close) + } + }) { + Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + if (appPlatform.isAndroid) { + SectionItemView({ + AlertManager.shared.hideAlert() + ModalManager.start.showModalCloseable { close -> + ScanProtocolServer(rhId) { server -> + addServer( + scope, + server, + userServers, + serverErrors, + rhId, + close = close + ) + } + } + } + ) { + Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + } + ) +} + +@Composable +fun ProtocolServerViewLink(serverProtocol: ServerProtocol, srv: UserServer, duplicateHosts: Set) { + val address = parseServerAddress(srv.server) + when { + address == null || !address.valid || address.serverProtocol != serverProtocol || address.hostnames.any { it in duplicateHosts } -> InvalidServer() + !srv.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary) + else -> ShowTestStatus(srv) + } + Spacer(Modifier.padding(horizontal = 4.dp)) + val text = address?.hostnames?.firstOrNull() ?: srv.server + if (srv.enabled) { + Text(text, color = MaterialTheme.colors.onBackground, maxLines = 1) + } else { + Text(text, maxLines = 1, color = MaterialTheme.colors.secondary) + } +} + +@Composable +private fun HowToButton() { + val uriHandler = LocalUriHandler.current + SettingsActionItem( + painterResource(MR.images.ic_open_in_new), + stringResource(MR.strings.how_to_use_your_servers), + { uriHandler.openUriCatching("https://simplex.chat/docs/server.html") }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary + ) +} + +@Composable +fun InvalidServer() { + Icon(painterResource(MR.images.ic_error), null, tint = MaterialTheme.colors.error) +} + +private suspend fun testServers( + testing: MutableState, + smpServers: List, + xftpServers: List, + m: ChatModel, + onUpdate: (ServerProtocol, List) -> Unit +) { + val smpResetStatus = resetTestStatus(smpServers) + onUpdate(ServerProtocol.SMP, smpResetStatus) + val xftpResetStatus = resetTestStatus(xftpServers) + onUpdate(ServerProtocol.XFTP, xftpResetStatus) + testing.value = true + val smpFailures = runServersTest(smpResetStatus, m) { onUpdate(ServerProtocol.SMP, it) } + val xftpFailures = runServersTest(xftpResetStatus, m) { onUpdate(ServerProtocol.XFTP, it) } + testing.value = false + val fs = smpFailures + xftpFailures + if (fs.isNotEmpty()) { + val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n") + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.smp_servers_test_failed), + text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg + ) + } +} + +private fun resetTestStatus(servers: List): List { + val copy = ArrayList(servers) + for ((index, server) in servers.withIndex()) { + if (server.enabled) { + copy.removeAt(index) + copy.add(index, server.copy(tested = null)) + } + } + return copy +} + +private suspend fun runServersTest(servers: List, m: ChatModel, onUpdated: (List) -> Unit): Map { + val fs: MutableMap = mutableMapOf() + val updatedServers = ArrayList(servers) + for ((index, server) in servers.withIndex()) { + if (server.enabled) { + interruptIfCancelled() + val (updatedServer, f) = testServerConnection(server, m) + updatedServers.removeAt(index) + updatedServers.add(index, updatedServer) + // toList() is important. Otherwise, Compose will not redraw the screen after first update + onUpdated(updatedServers.toList()) + if (f != null) { + fs[serverHostname(updatedServer.server)] = f + } + } + } + return fs +} + +fun deleteXFTPServer( + userServers: MutableState>, + operatorServersIndex: Int, + serverIndex: Int +) { + val serverIsSaved = userServers.value[operatorServersIndex].xftpServers[serverIndex].serverId != null + + if (serverIsSaved) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + xftpServers = this[operatorServersIndex].xftpServers.toMutableList().apply { + this[serverIndex] = this[serverIndex].copy(deleted = true) + } + ) + } + } else { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + xftpServers = this[operatorServersIndex].xftpServers.toMutableList().apply { + this.removeAt(serverIndex) + } + ) + } + } +} + +fun deleteSMPServer( + userServers: MutableState>, + operatorServersIndex: Int, + serverIndex: Int +) { + val serverIsSaved = userServers.value[operatorServersIndex].smpServers[serverIndex].serverId != null + + if (serverIsSaved) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + smpServers = this[operatorServersIndex].smpServers.toMutableList().apply { + this[serverIndex] = this[serverIndex].copy(deleted = true) + } + ) + } + } else { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + smpServers = this[operatorServersIndex].smpServers.toMutableList().apply { + this.removeAt(serverIndex) + } + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt similarity index 62% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt index 966f44cac7..56f16d4eb1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt @@ -1,29 +1,25 @@ -package chat.simplex.common.views.usersettings +package chat.simplex.common.views.usersettings.networkAndServers -import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.dp import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress -import chat.simplex.common.model.ServerCfg +import chat.simplex.common.model.UserServer import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.res.MR @Composable -expect fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) +expect fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) @Composable -fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) { +fun ScanProtocolServerLayout(rhId: Long?, onNext: (UserServer) -> Unit) { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr)) QRCodeScanner { text -> val res = parseServerAddress(text) if (res != null) { - onNext(ServerCfg(remoteHostId = rhId, text, false, null, false)) + onNext(UserServer(remoteHostId = rhId, null, text, false, null, false, false)) } else { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.smp_servers_invalid_address), diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 7236b22563..8b379d8212 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -109,6 +109,16 @@ Invalid display name! This display name is invalid. Please choose another name. Error switching profile! + Error saving servers + No message servers. + No servers to receive messages. + No servers for private message routing. + No media & file servers. + No servers to send files. + No servers to receive files. + For chat profile %s: + Errors in servers configuration. + Error accepting conditions Connection timeout @@ -750,6 +760,7 @@ Some servers failed the test: Scan server QR code Enter server manually + New server Preset server Your server Your server address @@ -1038,6 +1049,19 @@ Random passphrase is stored in settings as plaintext.\nYou can change it later. Use random passphrase + + Choose operators + Network operators + When more than one network operator is enabled, the app will use the servers of different operators for each conversation. + For example, if you receive messages via SimpleX Chat server, the app will use one of Flux servers for private routing. + Select network operators to use. + You can configure servers via settings. + Conditions will be accepted for enabled operators after 30 days. + You can configure operators in Network & servers settings. + Review later + Update + Continue + Incoming video call Incoming audio call @@ -1667,6 +1691,59 @@ Save group profile Error saving group profile + + Preset servers + Review conditions + Accepted conditions + Conditions will be automatically accepted for enabled operators on: %s. + Your servers + %s.]]> + %s.]]> + + + Operator + %s servers + Network operator + Website + Conditions accepted on: %s. + Conditions will be accepted on: %s. + Operator + Use servers + Use %s + Current conditions text couldn\'t be loaded, you can review conditions via this link: + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + View conditions + Accept conditions + Conditions of use + %s, accept conditions of use.]]> + Use for messages + To receive + For private routing + Added message servers + Use for files + To send + The servers for new files of your current chat profile + Added media & file servers + Open conditions + Open changes + + + Error updating server + Server protocol changed. + Server operator changed. + + + Operator server + Server added to operator %s. + Error adding server + TCP connection Reset to defaults @@ -2059,6 +2136,13 @@ Better message dates. Forward up to 20 messages at once. Delete or moderate up to 200 messages. + Network decentralization + The second preset operator in the app! + Enable flux + for better metadata privacy. + Improved chat navigation + - Open chat on the first unread message.\n- Jump to quoted messages. + View updated conditions seconds diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png new file mode 100644 index 0000000000..87f1373d75 Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png new file mode 100644 index 0000000000..e1d6dda4fe Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png new file mode 100644 index 0000000000..0793b0ee85 Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg new file mode 100644 index 0000000000..762c5846c1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt deleted file mode 100644 index 2d436dbbf0..0000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt +++ /dev/null @@ -1,9 +0,0 @@ -package chat.simplex.common.views.usersettings - -import androidx.compose.runtime.Composable -import chat.simplex.common.model.ServerCfg - -@Composable -actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) { - ScanProtocolServerLayout(rhId, onNext) -} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt new file mode 100644 index 0000000000..7d6f305a83 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt @@ -0,0 +1,9 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import androidx.compose.runtime.Composable +import chat.simplex.common.model.UserServer + +@Composable +actual fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) { + ScanProtocolServerLayout(rhId, onNext) +} diff --git a/cabal.project b/cabal.project index 7cc11fc583..793fc18952 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: a64c1aa2c41938c5e18cc49d08075f14e5d25f0d + tag: 97104988a307bd27b8bf5da7ed67455f3531d7ae source-repository-package type: git diff --git a/docs/rfcs/2024-10-27-server-operators.md b/docs/rfcs/2024-10-27-server-operators.md new file mode 100644 index 0000000000..5456d28f08 --- /dev/null +++ b/docs/rfcs/2024-10-27-server-operators.md @@ -0,0 +1,24 @@ +# Server operators + +## Problem + +All preconfigured servers operated by a single company create a risk that user connections can be analysed by aggregating transport information from these servers. + +The solution is to have more than one operator servers pre-configured in the app. + +For operators to be protected from any violations of rights of other users or third parties by the users who use servers of these operators, the users have to explicitely accept conditions of use with the operator, in the same way they accept conditions of use with SimpleX Chat Ltd by downloading the app. + +## Solution + +Allow to assign operators to servers, both with preconfigured operators and servers, and with user-defined operators. Agent added support for server roles, chat app could: +- allow assigning server roles only on the operator level. +- only on server level. +- on both, with server roles overriding operator roles (that would require a different type for server for chat app). + +For simplicity of both UX and logic it is probably better to allow assigning roles only on operators' level, and servers without set operators can be used for both roles. + +For agreements, it is sufficient to record the signatures of these agreements on users' devices, together with the copy of signed agreement (or its hash and version) in a separate table. The terms themselves could be: +- included in the app - either in code or in migration. +- referenced with a stable link to a particular commit. + +The first solution seems better, as it avoids any third party dependency, and the agreement size is relatively small (~31kb), to reduce size we can store it compressed. diff --git a/package.yaml b/package.yaml index 7bfa560a26..bbfc624bf9 100644 --- a/package.yaml +++ b/package.yaml @@ -29,6 +29,7 @@ dependencies: - email-validate == 2.3.* - exceptions == 0.10.* - filepath == 1.4.* + - file-embed == 0.0.15.* - http-types == 0.12.* - http2 >= 4.2.2 && < 4.3 - memory == 0.18.* @@ -38,6 +39,7 @@ dependencies: - optparse-applicative >= 0.15 && < 0.17 - random >= 1.1 && < 1.3 - record-hasfield == 1.0.* + - scientific ==0.3.7.* - simple-logger == 0.1.* - simplexmq >= 5.0 - socks == 0.6.* @@ -72,7 +74,7 @@ when: - bytestring == 0.10.* - process >= 1.6 && < 1.6.18 - template-haskell == 2.16.* - - text >= 1.2.3.0 && < 1.3 + - text >= 1.2.4.0 && < 1.3 library: source-dirs: src diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index b63bafcd96..e3985379d0 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a64c1aa2c41938c5e18cc49d08075f14e5d25f0d" = "1kf86vrh5zfrqyczfjcj3d2nagmqb0rwhhdc10fw5n8jcgmdw6rp"; + "https://github.com/simplex-chat/simplexmq.git"."97104988a307bd27b8bf5da7ed67455f3531d7ae" = "1xhk8cg4338d0cfjhdm2460p6nbvxfra80qnab2607nvy8wpddvl"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 4a5c3b6970..9ff84d28d7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -151,10 +151,13 @@ library Simplex.Chat.Migrations.M20241008_indexes Simplex.Chat.Migrations.M20241010_contact_requests_contact_id Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id + Simplex.Chat.Migrations.M20241027_server_operators Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.WebRTC + Simplex.Chat.Operators + Simplex.Chat.Operators.Conditions Simplex.Chat.Options Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol @@ -214,6 +217,7 @@ library , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -224,6 +228,7 @@ library , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplexmq >=5.0 , socks ==0.6.* @@ -250,7 +255,7 @@ library bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 executable simplex-bot main-is: Main.hs @@ -277,6 +282,7 @@ executable simplex-bot , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -287,6 +293,7 @@ executable simplex-bot , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat , simplexmq >=5.0 @@ -314,7 +321,7 @@ executable simplex-bot bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 executable simplex-bot-advanced main-is: Main.hs @@ -341,6 +348,7 @@ executable simplex-bot-advanced , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -351,6 +359,7 @@ executable simplex-bot-advanced , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat , simplexmq >=5.0 @@ -378,7 +387,7 @@ executable simplex-bot-advanced bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 executable simplex-broadcast-bot main-is: Main.hs @@ -408,6 +417,7 @@ executable simplex-broadcast-bot , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -418,6 +428,7 @@ executable simplex-broadcast-bot , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat , simplexmq >=5.0 @@ -445,7 +456,7 @@ executable simplex-broadcast-bot bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 executable simplex-chat main-is: Main.hs @@ -473,6 +484,7 @@ executable simplex-chat , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -483,6 +495,7 @@ executable simplex-chat , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat , simplexmq >=5.0 @@ -511,7 +524,7 @@ executable simplex-chat bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 executable simplex-directory-service main-is: Main.hs @@ -544,6 +557,7 @@ executable simplex-directory-service , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -554,6 +568,7 @@ executable simplex-directory-service , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat , simplexmq >=5.0 @@ -581,7 +596,7 @@ executable simplex-directory-service bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 test-suite simplex-chat-test type: exitcode-stdio-1.0 @@ -603,6 +618,7 @@ test-suite simplex-chat-test MarkdownTests MessageBatching MobileTests + OperatorTests ProtocolTests RandomServers RemoteTests @@ -643,6 +659,7 @@ test-suite simplex-chat-test , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , generic-random ==1.5.* , http-types ==0.12.* @@ -654,6 +671,7 @@ test-suite simplex-chat-test , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , silently ==1.2.* , simple-logger ==0.1.* , simplex-chat @@ -682,7 +700,7 @@ test-suite simplex-chat-test bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 if impl(ghc >= 9.6.2) build-depends: hspec ==2.11.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b74531b9e1..5906da57de 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3,9 +3,11 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} @@ -43,7 +45,7 @@ import Data.Functor (($>)) import Data.Functor.Identity import Data.Int (Int64) import Data.List (find, foldl', isSuffixOf, mapAccumL, partition, sortOn, zipWith4) -import Data.List.NonEmpty (NonEmpty (..), nonEmpty, toList, (<|)) +import Data.List.NonEmpty (NonEmpty (..), (<|)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -54,6 +56,7 @@ import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) +import Data.Type.Equality import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Data.Word (Word32) @@ -67,6 +70,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (MsgBatch (..), batchMessages) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events +import Simplex.Chat.Operators import Simplex.Chat.Options import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol @@ -97,7 +101,7 @@ import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, getFastNetworkConfig, ipAddressProtected, withLockMap) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), createAgentStore, defaultAgentConfig, enabledServerCfg, presetServerCfg) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), ServerRoles (..), allRoles, createAgentStore, defaultAgentConfig) import Simplex.Messaging.Agent.Lock (withLock) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) @@ -137,6 +141,34 @@ import qualified UnliftIO.Exception as E import UnliftIO.IO (hClose, hSeek, hTell, openFile) import UnliftIO.STM +operatorSimpleXChat :: NewServerOperator +operatorSimpleXChat = + ServerOperator + { operatorId = DBNewEntity, + operatorTag = Just OTSimplex, + tradeName = "SimpleX Chat", + legalName = Just "SimpleX Chat Ltd", + serverDomains = ["simplex.im"], + conditionsAcceptance = CARequired Nothing, + enabled = True, + smpRoles = allRoles, + xftpRoles = allRoles + } + +operatorFlux :: NewServerOperator +operatorFlux = + ServerOperator + { operatorId = DBNewEntity, + operatorTag = Just OTFlux, + tradeName = "Flux", + legalName = Just "InFlux Technologies Limited", + serverDomains = ["simplexonflux.com"], + conditionsAcceptance = CARequired Nothing, + enabled = False, + smpRoles = ServerRoles {storage = False, proxy = True}, + xftpRoles = allRoles + } + defaultChatConfig :: ChatConfig defaultChatConfig = ChatConfig @@ -147,13 +179,27 @@ defaultChatConfig = }, chatVRange = supportedChatVRange, confirmMigrations = MCConsole, - defaultServers = - DefaultAgentServers - { smp = _defaultSMPServers, - useSMP = 4, + -- this property should NOT use operator = Nothing + -- non-operator servers can be passed via options + presetServers = + PresetServers + { operators = + [ PresetOperator + { operator = Just operatorSimpleXChat, + smp = simplexChatSMPServers, + useSMP = 4, + xftp = map (presetServer True) $ L.toList defaultXFTPServers, + useXFTP = 3 + }, + PresetOperator + { operator = Just operatorFlux, + smp = fluxSMPServers, + useSMP = 3, + xftp = fluxXFTPServers, + useXFTP = 3 + } + ], ntf = _defaultNtfServers, - xftp = L.map (presetServerCfg True) defaultXFTPServers, - useXFTP = L.length defaultXFTPServers, netCfg = defaultNetworkConfig }, tbqSize = 1024, @@ -177,29 +223,52 @@ defaultChatConfig = chatHooks = defaultChatHooks } -_defaultSMPServers :: NonEmpty (ServerCfg 'PSMP) -_defaultSMPServers = - L.fromList $ - map - (presetServerCfg True) - [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", - "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", - "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion", - "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", - "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", - "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", - "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", - "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", - "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", - "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", - "smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion" +simplexChatSMPServers :: [NewUserServer 'PSMP] +simplexChatSMPServers = + map + (presetServer True) + [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", + "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", + "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion", + "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", + "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", + "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", + "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", + "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", + "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", + "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", + "smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion" + ] + <> map + (presetServer False) + [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", + "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", + "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" ] - <> map - (presetServerCfg False) - [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", - "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", - "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" - ] + +fluxSMPServers :: [NewUserServer 'PSMP] +fluxSMPServers = + map + (presetServer True) + [ "smp://xQW_ufMkGE20UrTlBl8QqceG1tbuylXhr9VOLPyRJmw=@smp1.simplexonflux.com,qb4yoanyl4p7o33yrknv4rs6qo7ugeb2tu2zo66sbebezs4cpyosarid.onion", + "smp://LDnWZVlAUInmjmdpQQoIo6FUinRXGe0q3zi5okXDE4s=@smp2.simplexonflux.com,yiqtuh3q4x7hgovkomafsod52wvfjucdljqbbipg5sdssnklgongxbqd.onion", + "smp://1jne379u7IDJSxAvXbWb_JgoE7iabcslX0LBF22Rej0=@smp3.simplexonflux.com,a5lm4k7ufei66cdck6fy63r4lmkqy3dekmmb7jkfdm5ivi6kfaojshad.onion", + "smp://xmAmqj75I9mWrUihLUlI0ZuNLXlIwFIlHRq5Pb6cHAU=@smp4.simplexonflux.com,qpcz2axyy66u26hfdd2e23uohcf3y6c36mn7dcuilcgnwjasnrvnxjqd.onion", + "smp://rWvBYyTamuRCBYb_KAn-nsejg879ndhiTg5Sq3k0xWA=@smp5.simplexonflux.com,4ao347qwiuluyd45xunmii4skjigzuuox53hpdsgbwxqafd4yrticead.onion", + "smp://PN7-uqLBToqlf1NxHEaiL35lV2vBpXq8Nj8BW11bU48=@smp6.simplexonflux.com,hury6ot3ymebbr2535mlp7gcxzrjpc6oujhtfxcfh2m4fal4xw5fq6qd.onion" + ] + +fluxXFTPServers :: [NewUserServer 'PXFTP] +fluxXFTPServers = + map + (presetServer True) + [ "xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com,apl3pumq3emwqtrztykyyoomdx4dg6ysql5zek2bi3rgznz7ai3odkid.onion", + "xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com,c5jjecisncnngysah3cz2mppediutfelco4asx65mi75d44njvua3xid.onion", + "xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com,dc4mohiubvbnsdfqqn7xhlhpqs5u4tjzp7xpz6v6corwvzvqjtaqqiqd.onion", + "xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com,4qq5pzier3i4yhpuhcrhfbl6j25udc4czoyascrj4yswhodhfwev3nyd.onion", + "xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com,q7itltdn32hjmgcqwhow4tay5ijetng3ur32bolssw32fvc5jrwvozad.onion", + "xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com,upvzf23ou6nrmaf3qgnhd6cn3d74tvivlmz3p7wdfwq6fhthjrjiiqid.onion" + ] _defaultNtfServers :: [NtfServer] _defaultNtfServers = @@ -236,16 +305,23 @@ newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Boo newChatController ChatDatabase {chatStore, agentStore} user - cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, deviceNameForRemote, confirmMigrations} + cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable, yesToUpMigrations}, deviceName, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} backgroundMode = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations - config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'} + config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'} firstTime = dbNew chatStore currentUser <- newTVarIO user + randomPresetServers <- chooseRandomServers presetServers' + let rndSrvs = L.toList randomPresetServers + operatorWithId (i, op) = (\o -> o {operatorId = DBEntityId i}) <$> pOperator op + opDomains = operatorDomains $ mapMaybe operatorWithId $ zip [1..] rndSrvs + agentSMP <- randomServerCfgs "agent SMP servers" SPSMP opDomains rndSrvs + agentXFTP <- randomServerCfgs "agent XFTP servers" SPXFTP opDomains rndSrvs + let randomAgentServers = RandomAgentServers {smpServers = agentSMP, xftpServers = agentXFTP} currentRemoteHost <- newTVarIO Nothing - servers <- agentServers config + servers <- withTransaction chatStore $ \db -> agentServers db config randomPresetServers randomAgentServers smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode agentAsync <- newTVarIO Nothing random <- liftIO C.newRandom @@ -281,6 +357,8 @@ newChatController ChatController { firstTime, currentUser, + randomPresetServers, + randomAgentServers, currentRemoteHost, smpAgent, agentAsync, @@ -318,28 +396,48 @@ newChatController contactMergeEnabled } where - configServers :: DefaultAgentServers - configServers = - let DefaultAgentServers {smp = defSmp, xftp = defXftp, netCfg} = defaultServers - smp' = maybe defSmp (L.map enabledServerCfg) (nonEmpty smpServers) - xftp' = maybe defXftp (L.map enabledServerCfg) (nonEmpty xftpServers) - in defaultServers {smp = smp', xftp = xftp', netCfg = updateNetworkConfig netCfg simpleNetCfg} - agentServers :: ChatConfig -> IO InitialAgentServers - agentServers config@ChatConfig {defaultServers = defServers@DefaultAgentServers {ntf, netCfg}} = do - users <- withTransaction chatStore getUsers - smp' <- getUserServers users SPSMP - xftp' <- getUserServers users SPXFTP - pure InitialAgentServers {smp = smp', xftp = xftp', ntf, netCfg} + presetServers' :: PresetServers + presetServers' = presetServers {operators = operators', netCfg = netCfg'} where - getUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => [User] -> SProtocolType p -> IO (Map UserId (NonEmpty (ServerCfg p))) - getUserServers users protocol = case users of - [] -> pure $ M.fromList [(1, cfgServers protocol defServers)] - _ -> M.fromList <$> initialServers - where - initialServers :: IO [(UserId, NonEmpty (ServerCfg p))] - initialServers = mapM (\u -> (aUserId u,) <$> userServers u) users - userServers :: User -> IO (NonEmpty (ServerCfg p)) - userServers user' = useServers config protocol <$> withTransaction chatStore (`getProtocolServers` user') + PresetServers {operators, netCfg} = presetServers + netCfg' = updateNetworkConfig netCfg simpleNetCfg + operators' = case (smpServers, xftpServers) of + ([], []) -> operators + (smpSrvs, []) -> L.map disableSMP operators <> [custom smpSrvs []] + ([], xftpSrvs) -> L.map disableXFTP operators <> [custom [] xftpSrvs] + (smpSrvs, xftpSrvs) -> [custom smpSrvs xftpSrvs] + disableSMP op@PresetOperator {smp} = (op :: PresetOperator) {smp = map disableSrv smp} + disableXFTP op@PresetOperator {xftp} = (op :: PresetOperator) {xftp = map disableSrv xftp} + disableSrv :: forall p. NewUserServer p -> NewUserServer p + disableSrv srv = (srv :: NewUserServer p) {enabled = False} + custom smpSrvs xftpSrvs = + PresetOperator + { operator = Nothing, + smp = map newUserServer smpSrvs, + useSMP = 0, + xftp = map newUserServer xftpSrvs, + useXFTP = 0 + } + randomServerCfgs :: UserProtocol p => String -> SProtocolType p -> [(Text, ServerOperator)] -> [PresetOperator] -> IO (NonEmpty (ServerCfg p)) + randomServerCfgs name p opDomains rndSrvs = + toJustOrError name $ L.nonEmpty $ agentServerCfgs p opDomains $ concatMap (pServers p) rndSrvs + agentServers :: DB.Connection -> ChatConfig -> NonEmpty PresetOperator -> RandomAgentServers -> IO InitialAgentServers + agentServers db ChatConfig {presetServers = PresetServers {ntf, netCfg}} presetOps as = do + users <- getUsers db + ops <- getUpdateServerOperators db presetOps (null users) + let opDomains = operatorDomains $ mapMaybe snd ops + (smp', xftp') <- unzip <$> mapM (getServers ops opDomains) users + pure InitialAgentServers {smp = M.fromList smp', xftp = M.fromList xftp', ntf, netCfg} + where + getServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [(Text, ServerOperator)] -> User -> IO ((UserId, NonEmpty (ServerCfg 'PSMP)), (UserId, NonEmpty (ServerCfg 'PXFTP))) + getServers ops opDomains user' = do + smpSrvs <- getProtocolServers db SPSMP user' + xftpSrvs <- getProtocolServers db SPXFTP user' + uss <- groupByOperator' (ops, smpSrvs, xftpSrvs) + ts <- getCurrentTime + uss' <- mapM (setUserServers' db user' ts . updatedUserServers) uss + let auId = aUserId user' + pure $ bimap (auId,) (auId,) $ useServers as opDomains uss' updateNetworkConfig :: NetworkConfig -> SimpleNetCfg -> NetworkConfig updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} = @@ -382,33 +480,47 @@ withFileLock :: String -> Int64 -> CM a -> CM a withFileLock name = withEntityLock name . CLFile {-# INLINE withFileLock #-} -useServers :: UserProtocol p => ChatConfig -> SProtocolType p -> [ServerCfg p] -> NonEmpty (ServerCfg p) -useServers ChatConfig {defaultServers} p = fromMaybe (cfgServers p defaultServers) . nonEmpty +useServers :: Foldable f => RandomAgentServers -> [(Text, ServerOperator)] -> f UserOperatorServers -> (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP)) +useServers as opDomains uss = + let smp' = useServerCfgs SPSMP as opDomains $ concatMap (servers' SPSMP) uss + xftp' = useServerCfgs SPXFTP as opDomains $ concatMap (servers' SPXFTP) uss + in (smp', xftp') -randomServers :: forall p. UserProtocol p => SProtocolType p -> ChatConfig -> IO (NonEmpty (ServerCfg p), [ServerCfg p]) -randomServers p ChatConfig {defaultServers} = do - let srvs = cfgServers p defaultServers - (enbldSrvs, dsbldSrvs) = L.partition (\ServerCfg {enabled} -> enabled) srvs - toUse = cfgServersToUse p defaultServers - if length enbldSrvs <= toUse - then pure (srvs, []) - else do - (enbldSrvs', srvsToDisable) <- splitAt toUse <$> shuffle enbldSrvs - let dsbldSrvs' = map (\srv -> (srv :: ServerCfg p) {enabled = False}) srvsToDisable - srvs' = sortOn server' $ enbldSrvs' <> dsbldSrvs' <> dsbldSrvs - pure (fromMaybe srvs $ L.nonEmpty srvs', srvs') +useServerCfgs :: forall p. UserProtocol p => SProtocolType p -> RandomAgentServers -> [(Text, ServerOperator)] -> [UserServer p] -> NonEmpty (ServerCfg p) +useServerCfgs p RandomAgentServers {smpServers, xftpServers} opDomains = + fromMaybe (rndAgentServers p) . L.nonEmpty . agentServerCfgs p opDomains where - server' ServerCfg {server = ProtoServerWithAuth srv _} = srv + rndAgentServers :: SProtocolType p -> NonEmpty (ServerCfg p) + rndAgentServers = \case + SPSMP -> smpServers + SPXFTP -> xftpServers -cfgServers :: UserProtocol p => SProtocolType p -> DefaultAgentServers -> NonEmpty (ServerCfg p) -cfgServers p DefaultAgentServers {smp, xftp} = case p of - SPSMP -> smp - SPXFTP -> xftp +chooseRandomServers :: PresetServers -> IO (NonEmpty PresetOperator) +chooseRandomServers PresetServers {operators} = + forM operators $ \op -> do + smp' <- opSrvs SPSMP op + xftp' <- opSrvs SPXFTP op + pure (op :: PresetOperator) {smp = smp', xftp = xftp'} + where + opSrvs :: forall p. UserProtocol p => SProtocolType p -> PresetOperator -> IO [NewUserServer p] + opSrvs p op = do + let srvs = pServers p op + toUse = operatorServersToUse p op + (enbldSrvs, dsbldSrvs) = partition (\UserServer {enabled} -> enabled) srvs + if toUse <= 0 || toUse >= length enbldSrvs + then pure srvs + else do + (enbldSrvs', srvsToDisable) <- splitAt toUse <$> shuffle enbldSrvs + let dsbldSrvs' = map (\srv -> (srv :: NewUserServer p) {enabled = False}) srvsToDisable + pure $ sortOn server' $ enbldSrvs' <> dsbldSrvs' <> dsbldSrvs + server' UserServer {server = ProtoServerWithAuth srv _} = srv -cfgServersToUse :: UserProtocol p => SProtocolType p -> DefaultAgentServers -> Int -cfgServersToUse p DefaultAgentServers {useSMP, useXFTP} = case p of - SPSMP -> useSMP - SPXFTP -> useXFTP +toJustOrError :: String -> Maybe a -> IO a +toJustOrError name = \case + Just a -> pure a + Nothing -> do + putStrLn $ name <> ": expected Just, exiting" + E.throwIO $ userError name -- enableSndFiles has no effect when mainApp is True startChatController :: Bool -> Bool -> CM' (Async ()) @@ -438,7 +550,7 @@ startChatController mainApp enableSndFiles = do startXFTP startWorkers = do tmp <- readTVarIO =<< asks tempDirectory runExceptT (withAgent $ \a -> startWorkers a tmp) >>= \case - Left e -> liftIO $ print $ "Error starting XFTP workers: " <> show e + Left e -> liftIO $ putStrLn $ "Error starting XFTP workers: " <> show e Right _ -> pure () startCleanupManager = do cleanupAsync <- asks cleanupManagerAsync @@ -552,39 +664,43 @@ processChatCommand' vr = \case forM_ profile $ \Profile {displayName} -> checkValidName displayName p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile u <- asks currentUser - (smp, smpServers) <- chooseServers SPSMP - (xftp, xftpServers) <- chooseServers SPXFTP users <- withFastStore' getUsers forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash} -> when (n == displayName) . throwChatError $ if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""} - auId <- withAgent (\a -> createUser a smp xftp) + (uss, (smp', xftp')) <- chooseServers =<< readTVarIO u + auId <- withAgent $ \a -> createUser a smp' xftp' ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure - user <- withFastStore $ \db -> createUserRecordAt db (AgentUserId auId) p True ts - createPresetContactCards user `catchChatError` \_ -> pure () - withFastStore $ \db -> createNoteFolder db user - storeServers user smpServers - storeServers user xftpServers + user <- withFastStore $ \db -> do + user <- createUserRecordAt db (AgentUserId auId) p True ts + mapM_ (setUserServers db user ts) uss + createPresetContactCards db user `catchStoreError` \_ -> pure () + createNoteFolder db user + pure user atomically . writeTVar u $ Just user pure $ CRActiveUser user where - createPresetContactCards :: User -> CM () - createPresetContactCards user = - withFastStore $ \db -> do - createContact db user simplexStatusContactProfile - createContact db user simplexTeamContactProfile - chooseServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> CM (NonEmpty (ServerCfg p), [ServerCfg p]) - chooseServers protocol = - asks currentUser >>= readTVarIO >>= \case - Nothing -> asks config >>= liftIO . randomServers protocol - Just user -> chosenServers =<< withFastStore' (`getProtocolServers` user) - where - chosenServers servers = do - cfg <- asks config - pure (useServers cfg protocol servers, servers) - storeServers user servers = - unless (null servers) . withFastStore $ - \db -> overwriteProtocolServers db user servers + createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO () + createPresetContactCards db user = do + createContact db user simplexStatusContactProfile + createContact db user simplexTeamContactProfile + chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) + chooseServers user_ = do + as <- asks randomAgentServers + mapM (withFastStore . flip getUserServers >=> liftIO . groupByOperator) user_ >>= \case + Just uss -> do + let opDomains = operatorDomains $ mapMaybe operator' uss + uss' = map copyServers uss + pure $ (uss',) $ useServers as opDomains uss + Nothing -> do + ps <- asks randomPresetServers + uss <- presetUserServers <$> withFastStore' (\db -> getUpdateServerOperators db ps True) + let RandomAgentServers {smpServers = smp', xftpServers = xftp'} = as + pure (uss, (smp', xftp')) + copyServers :: UserOperatorServers -> UpdatedUserOperatorServers + copyServers UserOperatorServers {operator, smpServers, xftpServers} = + let new srv = AUS SDBNew srv {serverId = DBNewEntity} + in UpdatedUserOperatorServers {operator, smpServers = map new smpServers, xftpServers = map new xftpServers} coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 ListUsers -> CRUsersList <$> withFastStore' getUsersInfo @@ -1482,25 +1598,96 @@ processChatCommand' vr = \case msgs <- lift $ withAgent' $ \a -> getConnectionMessages a acIds let ntfMsgs = L.map (\msg -> receivedMsgInfo <$> msg) msgs pure $ CRConnNtfMessages ntfMsgs - APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do - cfg@ChatConfig {defaultServers} <- asks config - servers <- withFastStore' (`getProtocolServers` user) - pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p (useServers cfg p servers) (cfgServers p defaultServers) - GetUserProtoServers aProtocol -> withUser $ \User {userId} -> - processChatCommand $ APIGetUserProtoServers userId aProtocol - APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) - | null servers || any (\ServerCfg {enabled} -> enabled) servers -> withUserId userId $ \user -> withServerProtocol p $ do - withFastStore $ \db -> overwriteProtocolServers db user servers - cfg <- asks config - lift $ withAgent' $ \a -> setProtocolServers a (aUserId user) $ useServers cfg p servers - ok user - | otherwise -> withUserId userId $ \user -> pure $ chatCmdError (Just user) "all servers are disabled" - SetUserProtoServers serversConfig -> withUser $ \User {userId} -> - processChatCommand $ APISetUserProtoServers userId serversConfig + GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do + srvs <- withFastStore (`getUserServers` user) + liftIO $ CRUserServers user <$> groupByOperator (protocolServers p srvs) + SetUserProtoServers (AProtocolType (p :: SProtocolType p)) srvs -> withUser $ \user@User {userId} -> withServerProtocol p $ do + userServers_ <- liftIO . groupByOperator =<< withFastStore (`getUserServers` user) + case L.nonEmpty userServers_ of + Nothing -> throwChatError $ CECommandError "no servers" + Just userServers -> case srvs of + [] -> throwChatError $ CECommandError "no servers" + _ -> do + srvs' <- mapM aUserServer srvs + processChatCommand $ APISetUserServers userId $ L.map (updatedServers p srvs') userServers + where + aUserServer :: AProtoServerWithAuth -> CM (AUserServer p) + aUserServer (AProtoServerWithAuth p' srv) = case testEquality p p' of + Just Refl -> pure $ AUS SDBNew $ newUserServer srv + Nothing -> throwChatError $ CECommandError $ "incorrect server protocol: " <> B.unpack (strEncode srv) APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user -> lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand $ APITestProtoServer userId srv + APIGetServerOperators -> CRServerOperatorConditions <$> withFastStore getServerOperators + APISetServerOperators operators -> do + as <- asks randomAgentServers + (opsConds, srvs) <- withFastStore $ \db -> do + liftIO $ setServerOperators db operators + opsConds <- getServerOperators db + let ops = serverOperators opsConds + ops' = map Just ops <> [Nothing] + opDomains = operatorDomains ops + liftIO $ fmap (opsConds,) . mapM (getServers db as ops' opDomains) =<< getUsers db + lift $ withAgent' $ \a -> forM_ srvs $ \(auId, (smp', xftp')) -> do + setProtocolServers a auId smp' + setProtocolServers a auId xftp' + pure $ CRServerOperatorConditions opsConds + where + getServers :: DB.Connection -> RandomAgentServers -> [Maybe ServerOperator] -> [(Text, ServerOperator)] -> User -> IO (UserId, (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) + getServers db as ops opDomains user = do + smpSrvs <- getProtocolServers db SPSMP user + xftpSrvs <- getProtocolServers db SPXFTP user + uss <- groupByOperator (ops, smpSrvs, xftpSrvs) + pure $ (aUserId user,) $ useServers as opDomains uss + SetServerOperators operatorsRoles -> do + ops <- serverOperators <$> withFastStore getServerOperators + ops' <- mapM (updateOp ops) operatorsRoles + processChatCommand $ APISetServerOperators ops' + where + updateOp :: [ServerOperator] -> ServerOperatorRoles -> CM ServerOperator + updateOp ops r = + case find (\ServerOperator {operatorId = DBEntityId opId} -> operatorId' r == opId) ops of + Just op -> pure op {enabled = enabled' r, smpRoles = smpRoles' r, xftpRoles = xftpRoles' r} + Nothing -> throwError $ ChatErrorStore $ SEOperatorNotFound $ operatorId' r + APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> do + CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user) + APISetUserServers userId userServers -> withUserId userId $ \user -> do + errors <- validateAllUsersServers userId $ L.toList userServers + unless (null errors) $ throwChatError (CECommandError $ "user servers validation error(s): " <> show errors) + uss <- withFastStore $ \db -> do + ts <- liftIO getCurrentTime + mapM (setUserServers db user ts) userServers + as <- asks randomAgentServers + lift $ withAgent' $ \a -> do + let auId = aUserId user + opDomains = operatorDomains $ mapMaybe operator' $ L.toList uss + (smp', xftp') = useServers as opDomains uss + setProtocolServers a auId smp' + setProtocolServers a auId xftp' + ok_ + APIValidateServers userId userServers -> withUserId userId $ \user -> + CRUserServersValidation user <$> validateAllUsersServers userId userServers + APIGetUsageConditions -> do + (usageConditions, acceptedConditions) <- withFastStore $ \db -> do + usageConditions <- getCurrentUsageConditions db + acceptedConditions <- liftIO $ getLatestAcceptedConditions db + pure (usageConditions, acceptedConditions) + -- TODO if db commit is different from source commit, conditionsText should be nothing in response + pure + CRUsageConditions + { usageConditions, + conditionsText = usageConditionsText, + acceptedConditions + } + APISetConditionsNotified condId -> do + currentTs <- liftIO getCurrentTime + withFastStore' $ \db -> setConditionsNotified db condId currentTs + ok_ + APIAcceptConditions condId opIds -> withFastStore $ \db -> do + currentTs <- liftIO getCurrentTime + acceptConditions db condId opIds currentTs + CRServerOperatorConditions <$> getServerOperators db APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do @@ -1753,8 +1940,9 @@ processChatCommand' vr = \case canKeepLink (CRInvitationUri crData _) newUser = do let ConnReqUriData {crSmpQueues = q :| _} = crData SMPQueueUri {queueAddress = SMPQueueAddress {smpServer}} = q - cfg <- asks config - newUserServers <- L.map (\ServerCfg {server} -> protoServer server) . useServers cfg SPSMP <$> withFastStore' (`getProtocolServers` newUser) + newUserServers <- + map protoServer' . L.filter (\ServerCfg {enabled} -> enabled) + <$> getKnownAgentServers SPSMP newUser pure $ smpServer `elem` newUserServers updateConnRecord user@User {userId} conn@PendingContactConnection {customUserProfileId} newUser = do withAgent $ \a -> changeConnectionUser a (aUserId user) (aConnId' conn) (aUserId newUser) @@ -2088,7 +2276,7 @@ processChatCommand' vr = \case where changeMemberRole user gInfo members m gEvent = do let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m - assertUserGroupRole gInfo $ maximum [GRAdmin, mRole, memRole] + assertUserGroupRole gInfo $ maximum ([GRAdmin, mRole, memRole] :: [GroupMemberRole]) withGroupLock "memberRole" groupId . procCmd $ do unless (mRole == memRole) $ do withFastStore' $ \db -> updateGroupMemberRole db user m memRole @@ -2486,14 +2674,15 @@ processChatCommand' vr = \case pure $ CRAgentSubsTotal user subsTotal hasSession GetAgentServersSummary userId -> withUserId userId $ \user -> do agentServersSummary <- lift $ withAgent' getAgentServersSummary - cfg <- asks config - (users, smpServers, xftpServers) <- - withStore' $ \db -> (,,) <$> getUsers db <*> getServers db cfg user SPSMP <*> getServers db cfg user SPXFTP - let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers _defaultNtfServers - pure $ CRAgentServersSummary user presentedServersSummary + withStore' $ \db -> do + users <- getUsers db + smpServers <- getServers db user SPSMP + xftpServers <- getServers db user SPXFTP + let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers _defaultNtfServers + pure $ CRAgentServersSummary user presentedServersSummary where - getServers :: (ProtocolTypeI p, UserProtocol p) => DB.Connection -> ChatConfig -> User -> SProtocolType p -> IO (NonEmpty (ProtocolServer p)) - getServers db cfg user p = L.map (\ServerCfg {server} -> protoServer server) . useServers cfg p <$> getProtocolServers db user + getServers :: ProtocolTypeI p => DB.Connection -> User -> SProtocolType p -> IO [ProtocolServer p] + getServers db user p = map (\UserServer {server} -> protoServer server) <$> getProtocolServers db p user ResetAgentServersStats -> withAgent resetAgentServersStats >> ok_ GetAgentWorkers -> lift $ CRAgentWorkersSummary <$> withAgent' getAgentWorkersSummary GetAgentWorkersDetails -> lift $ CRAgentWorkersDetails <$> withAgent' getAgentWorkersDetails @@ -2784,6 +2973,20 @@ processChatCommand' vr = \case withServerProtocol p action = case userProtocol p of Just Dict -> action _ -> throwChatError $ CEServerProtocol $ AProtocolType p + validateAllUsersServers :: UserServersClass u => Int64 -> [u] -> CM [UserServersError] + validateAllUsersServers currUserId userServers = withFastStore $ \db -> do + users' <- filter (\User {userId} -> userId /= currUserId) <$> liftIO (getUsers db) + others <- mapM (getUserOperatorServers db) users' + pure $ validateUserServers userServers others + where + getUserOperatorServers :: DB.Connection -> User -> ExceptT StoreError IO (User, [UserOperatorServers]) + getUserOperatorServers db user = do + uss <- liftIO . groupByOperator =<< getUserServers db user + pure (user, map updatedUserSrvs uss) + updatedUserSrvs uss = uss {operator = updatedOp <$> operator' uss} :: UserOperatorServers + updatedOp op = fromMaybe op $ find matchingOp $ mapMaybe operator' userServers + where + matchingOp op' = operatorId op' == operatorId op forwardFile :: ChatName -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> CM ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do withStore (\db -> getFileTransfer db user fileId) >>= \case @@ -3225,6 +3428,23 @@ processChatCommand' vr = \case msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId) CRQueueInfo user msgInfo <$> withAgent (`getConnectionQueueInfo` acId) +protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) +protocolServers p (operators, smpServers, xftpServers) = case p of + SPSMP -> (operators, smpServers, []) + SPXFTP -> (operators, [], xftpServers) + +-- disable preset and replace custom servers (groupByOperator always adds custom) +updatedServers :: forall p. UserProtocol p => SProtocolType p -> [AUserServer p] -> UserOperatorServers -> UpdatedUserOperatorServers +updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = case p' of + SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers) + SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers) + where + u = uncurry $ UpdatedUserOperatorServers operator + updateSrvs :: [UserServer p] -> [AUserServer p] + updateSrvs pSrvs = map disableSrv pSrvs <> maybe srvs (const []) operator + disableSrv srv@UserServer {preset} = + AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True} + type ComposeMessageReq = (ComposedMessage, Maybe CIForwardedFrom) contactCITimed :: Contact -> CM (Maybe CITimed) @@ -3611,8 +3831,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer] getUnknownSrvs srvs = do - cfg <- asks config - knownSrvs <- L.map (\ServerCfg {server} -> protoServer server) . useServers cfg SPXFTP <$> withStore' (`getProtocolServers` user) + knownSrvs <- L.map protoServer' <$> getKnownAgentServers SPXFTP user pure $ filter (`notElem` knownSrvs) srvs ipProtectedForSrvs :: [XFTPServer] -> CM Bool ipProtectedForSrvs srvs = do @@ -3626,6 +3845,17 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} toView $ CRChatItemUpdated user aci throwChatError $ CEFileNotApproved fileId unknownSrvs +getKnownAgentServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> User -> CM (NonEmpty (ServerCfg p)) +getKnownAgentServers p user = do + as <- asks randomAgentServers + withStore $ \db -> do + opDomains <- operatorDomains . serverOperators <$> getServerOperators db + srvs <- liftIO $ getProtocolServers db p user + pure $ useServerCfgs p as opDomains srvs + +protoServer' :: ServerCfg p -> ProtocolServer p +protoServer' ServerCfg {server} = protoServer server + getNetworkConfig :: CM' NetworkConfig getNetworkConfig = withAgent' $ liftIO . getFastNetworkConfig @@ -3824,7 +4054,7 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do (sftConns, sfts) <- getSndFileTransferConns (rftConns, rfts) <- getRcvFileTransferConns (pcConns, pcs) <- getPendingContactConns - let conns = concat [ctConns, ucConns, mConns, sftConns, rftConns, pcConns] + let conns = concat ([ctConns, ucConns, mConns, sftConns, rftConns, pcConns] :: [[ConnId]]) pure (conns, cts, ucs, gs, ms, sfts, rfts, pcs) -- subscribe using batched commands rs <- withAgent $ \a -> agentBatchSubscribe a conns @@ -4632,7 +4862,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct) SWITCH qd phase cStats -> do toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats) - when (phase `elem` [SPStarted, SPCompleted]) $ case qd of + when (phase == SPStarted || phase == SPCompleted) $ case qd of QDRcv -> createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCESwitchQueue phase Nothing) Nothing QDSnd -> createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing RSYNC rss cryptoErr_ cStats -> @@ -4917,7 +5147,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (Just fileDescrText, Just msgId) -> do partSize <- asks $ xftpDescrPartSize . config let parts = splitFileDescr partSize fileDescrText - pure . toList $ L.map (XMsgFileDescr msgId) parts + pure . L.toList $ L.map (XMsgFileDescr msgId) parts _ -> pure [] let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents GroupMember {memberId} = sender @@ -5043,7 +5273,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when continued $ sendPendingGroupMessages user m conn SWITCH qd phase cStats -> do toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) - when (phase `elem` [SPStarted, SPCompleted]) $ case qd of + when (phase == SPStarted || phase == SPCompleted) $ case qd of QDRcv -> createInternalChatItem user (CDGroupSnd gInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing RSYNC rss cryptoErr_ cStats -> @@ -6607,15 +6837,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageWarning "x.grp.mem.con: neither member is invitee" where inviteeXGrpMemCon :: GroupMemberIntro -> CM () - inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} - | introStatus == GMIntroReConnected = updateStatus introId GMIntroConnected - | introStatus `elem` [GMIntroToConnected, GMIntroConnected] = pure () - | otherwise = updateStatus introId GMIntroToConnected + inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of + GMIntroReConnected -> updateStatus introId GMIntroConnected + GMIntroToConnected -> pure () + GMIntroConnected -> pure () + _ -> updateStatus introId GMIntroToConnected forwardMemberXGrpMemCon :: GroupMemberIntro -> CM () - forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} - | introStatus == GMIntroToConnected = updateStatus introId GMIntroConnected - | introStatus `elem` [GMIntroReConnected, GMIntroConnected] = pure () - | otherwise = updateStatus introId GMIntroReConnected + forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of + GMIntroToConnected -> updateStatus introId GMIntroConnected + GMIntroReConnected -> pure () + GMIntroConnected -> pure () + _ -> updateStatus introId GMIntroReConnected updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> CM () @@ -8080,14 +8312,19 @@ chatCommandP = "/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP), "/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP), "/ntf test " *> (TestProtoServer . AProtoServerWithAuth SPNTF <$> strP), - "/_servers " *> (APISetUserProtoServers <$> A.decimal <* A.space <*> srvCfgP), - "/smp " *> (SetUserProtoServers . APSC SPSMP . ProtoServersConfig . map enabledServerCfg <$> protocolServersP), - "/smp default" $> SetUserProtoServers (APSC SPSMP $ ProtoServersConfig []), - "/xftp " *> (SetUserProtoServers . APSC SPXFTP . ProtoServersConfig . map enabledServerCfg <$> protocolServersP), - "/xftp default" $> SetUserProtoServers (APSC SPXFTP $ ProtoServersConfig []), - "/_servers " *> (APIGetUserProtoServers <$> A.decimal <* A.space <*> strP), + "/smp " *> (SetUserProtoServers (AProtocolType SPSMP) . map (AProtoServerWithAuth SPSMP) <$> protocolServersP), + "/xftp " *> (SetUserProtoServers (AProtocolType SPXFTP) . map (AProtoServerWithAuth SPXFTP) <$> protocolServersP), "/smp" $> GetUserProtoServers (AProtocolType SPSMP), "/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), + "/_operators" $> APIGetServerOperators, + "/_operators " *> (APISetServerOperators <$> jsonP), + "/operators " *> (SetServerOperators . L.fromList <$> operatorRolesP `A.sepBy1` A.char ','), + "/_servers " *> (APIGetUserServers <$> A.decimal), + "/_servers " *> (APISetUserServers <$> A.decimal <* A.space <*> jsonP), + "/_validate_servers " *> (APIValidateServers <$> A.decimal <* A.space <*> jsonP), + "/_conditions" $> APIGetUsageConditions, + "/_conditions_notified " *> (APISetConditionsNotified <$> A.decimal), + "/_accept_conditions " *> (APIAcceptConditions <$> A.decimal <*> _strP), "/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal), "/ttl " *> (SetChatItemTTL <$> ciTTL), "/_ttl " *> (APIGetChatItemTTL <$> A.decimal), @@ -8411,6 +8648,20 @@ chatCommandP = optional ("yes" *> A.space) *> (TMEEnableSetTTL <$> timedTTLP) <|> ("yes" $> TMEEnableKeepTTL) <|> ("no" $> TMEDisableKeepTTL) + operatorRolesP = do + operatorId' <- A.decimal + enabled' <- A.char ':' *> onOffP + smpRoles' <- (":smp=" *> srvRolesP) <|> pure allRoles + xftpRoles' <- (":xftp=" *> srvRolesP) <|> pure allRoles + pure ServerOperatorRoles {operatorId', enabled', smpRoles', xftpRoles'} + srvRolesP = srvRoles <$?> A.takeTill (\c -> c == ':' || c == ',') + where + srvRoles = \case + "off" -> Right $ ServerRoles False False + "proxy" -> Right ServerRoles {storage = False, proxy = True} + "storage" -> Right ServerRoles {storage = True, proxy = False} + "on" -> Right allRoles + _ -> Left "bad ServerRoles" netCfgP = do socksProxy <- "socks=" *> ("off" $> Nothing <|> "on" $> Just defaultSocksProxyWithAuth <|> Just <$> strP) socksMode <- " socks-mode=" *> strP <|> pure SMAlways @@ -8431,7 +8682,6 @@ chatCommandP = onOffP (Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP))) (pure Nothing) - srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (jsonP <|> text1P)) text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 4be6086acb..23aa632478 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -35,7 +35,6 @@ import qualified Data.ByteArray as BA import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (ord) -import Data.Constraint (Dict (..)) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) @@ -57,6 +56,7 @@ import Simplex.Chat.Call import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.Types @@ -84,7 +84,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), ProtocolTypeI, QueueId, SMPMsgMeta (..), SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost) @@ -132,7 +132,7 @@ data ChatConfig = ChatConfig { agentConfig :: AgentConfig, chatVRange :: VersionRangeChat, confirmMigrations :: MigrationConfirmation, - defaultServers :: DefaultAgentServers, + presetServers :: PresetServers, tbqSize :: Natural, fileChunkSize :: Integer, xftpDescrPartSize :: Int, @@ -154,6 +154,12 @@ data ChatConfig = ChatConfig chatHooks :: ChatHooks } +data RandomAgentServers = RandomAgentServers + { smpServers :: NonEmpty (ServerCfg 'PSMP), + xftpServers :: NonEmpty (ServerCfg 'PXFTP) + } + deriving (Show) + -- The hooks can be used to extend or customize chat core in mobile or CLI clients. data ChatHooks = ChatHooks { -- preCmdHook can be used to process or modify the commands before they are processed. @@ -172,14 +178,12 @@ defaultChatHooks = eventHook = \_ -> pure } -data DefaultAgentServers = DefaultAgentServers - { smp :: NonEmpty (ServerCfg 'PSMP), - useSMP :: Int, +data PresetServers = PresetServers + { operators :: NonEmpty PresetOperator, ntf :: [NtfServer], - xftp :: NonEmpty (ServerCfg 'PXFTP), - useXFTP :: Int, netCfg :: NetworkConfig } + deriving (Show) data InlineFilesConfig = InlineFilesConfig { offerChunks :: Integer, @@ -203,6 +207,8 @@ data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLite data ChatController = ChatController { currentUser :: TVar (Maybe User), + randomPresetServers :: NonEmpty PresetOperator, + randomAgentServers :: RandomAgentServers, currentRemoteHost :: TVar (Maybe RemoteHostId), firstTime :: Bool, smpAgent :: AgentClient, @@ -346,12 +352,19 @@ data ChatCommand | APIGetGroupLink GroupId | APICreateMemberContact GroupId GroupMemberId | APISendMemberContactInvitation {contactId :: ContactId, msgContent_ :: Maybe MsgContent} - | APIGetUserProtoServers UserId AProtocolType | GetUserProtoServers AProtocolType - | APISetUserProtoServers UserId AProtoServersConfig - | SetUserProtoServers AProtoServersConfig + | SetUserProtoServers AProtocolType [AProtoServerWithAuth] | APITestProtoServer UserId AProtoServerWithAuth | TestProtoServer AProtoServerWithAuth + | APIGetServerOperators + | APISetServerOperators (NonEmpty ServerOperator) + | SetServerOperators (NonEmpty ServerOperatorRoles) + | APIGetUserServers UserId + | APISetUserServers UserId (NonEmpty UpdatedUserOperatorServers) + | APIValidateServers UserId [UpdatedUserOperatorServers] -- response is CRUserServersValidation + | APIGetUsageConditions + | APISetConditionsNotified Int64 + | APIAcceptConditions Int64 (NonEmpty Int64) | APISetChatItemTTL UserId (Maybe Int64) | SetChatItemTTL (Maybe Int64) | APIGetChatItemTTL UserId @@ -577,8 +590,11 @@ data ChatResponse | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} - | CRUserProtoServers {user :: User, servers :: AUserProtoServers} | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} + | CRServerOperatorConditions {conditions :: ServerOperatorConditions} + | CRUserServers {user :: User, userServers :: [UserOperatorServers]} + | CRUserServersValidation {user :: User, serverErrors :: [UserServersError]} + | CRUsageConditions {usageConditions :: UsageConditions, conditionsText :: Text, acceptedConditions :: Maybe UsageConditions} | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} | CRContactInfo {user :: User, contact :: Contact, connectionStats_ :: Maybe ConnectionStats, customUserProfile :: Maybe Profile} @@ -943,24 +959,6 @@ instance ToJSON AgentQueueId where toJSON = strToJSON toEncoding = strToJEncoding -data ProtoServersConfig p = ProtoServersConfig {servers :: [ServerCfg p]} - deriving (Show) - -data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) (ProtoServersConfig p) - -deriving instance Show AProtoServersConfig - -data UserProtoServers p = UserProtoServers - { serverProtocol :: SProtocolType p, - protoServers :: NonEmpty (ServerCfg p), - presetServers :: NonEmpty (ServerCfg p) - } - deriving (Show) - -data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p) - -deriving instance Show AUserProtoServers - data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} deriving (Show) @@ -1562,28 +1560,28 @@ $(JQ.deriveJSON defaultJSON ''CoreVersionInfo) $(JQ.deriveJSON defaultJSON ''SlowSQLQuery) -instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where - parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) +-- instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where +-- parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) -instance ProtocolTypeI p => FromJSON (UserProtoServers p) where - parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers) +-- instance ProtocolTypeI p => FromJSON (UserProtoServers p) where +-- parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers) -instance ProtocolTypeI p => ToJSON (UserProtoServers p) where - toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers) - toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) +-- instance ProtocolTypeI p => ToJSON (UserProtoServers p) where +-- toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers) +-- toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) -instance FromJSON AUserProtoServers where - parseJSON v = J.withObject "AUserProtoServers" parse v - where - parse o = do - AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" - case userProtocol p of - Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v - Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p +-- instance FromJSON AUserProtoServers where +-- parseJSON v = J.withObject "AUserProtoServers" parse v +-- where +-- parse o = do +-- AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" +-- case userProtocol p of +-- Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v +-- Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p -instance ToJSON AUserProtoServers where - toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s - toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s +-- instance ToJSON AUserProtoServers where +-- toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s +-- toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState) diff --git a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs new file mode 100644 index 0000000000..1316e3c006 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs @@ -0,0 +1,58 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20241027_server_operators where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20241027_server_operators :: Query +m20241027_server_operators = + [sql| +CREATE TABLE server_operators ( + server_operator_id INTEGER PRIMARY KEY AUTOINCREMENT, + server_operator_tag TEXT, + trade_name TEXT NOT NULL, + legal_name TEXT, + server_domains TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + smp_role_storage INTEGER NOT NULL DEFAULT 1, + smp_role_proxy INTEGER NOT NULL DEFAULT 1, + xftp_role_storage INTEGER NOT NULL DEFAULT 1, + xftp_role_proxy INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE usage_conditions ( + usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT, + conditions_commit TEXT NOT NULL UNIQUE, + notified_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE operator_usage_conditions ( + operator_usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT, + server_operator_id INTEGER REFERENCES server_operators (server_operator_id) ON DELETE SET NULL ON UPDATE CASCADE, + server_operator_tag TEXT, + conditions_commit TEXT NOT NULL, + accepted_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_conditions(server_operator_id); +CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions(conditions_commit, server_operator_id); +|] + +down_m20241027_server_operators :: Query +down_m20241027_server_operators = + [sql| +DROP INDEX idx_operator_usage_conditions_conditions_commit; +DROP INDEX idx_operator_usage_conditions_server_operator_id; + +DROP TABLE operator_usage_conditions; +DROP TABLE usage_conditions; +DROP TABLE server_operators; + +DELETE FROM protocol_servers WHERE host LIKE "%.simplexonflux.com,%"; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index f16ca6b870..0dc68034e7 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -589,6 +589,35 @@ CREATE TABLE note_folders( unread_chat INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE app_settings(app_settings TEXT NOT NULL); +CREATE TABLE server_operators( + server_operator_id INTEGER PRIMARY KEY AUTOINCREMENT, + server_operator_tag TEXT, + trade_name TEXT NOT NULL, + legal_name TEXT, + server_domains TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + smp_role_storage INTEGER NOT NULL DEFAULT 1, + smp_role_proxy INTEGER NOT NULL DEFAULT 1, + xftp_role_storage INTEGER NOT NULL DEFAULT 1, + xftp_role_proxy INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE TABLE usage_conditions( + usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT, + conditions_commit TEXT NOT NULL UNIQUE, + notified_at TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE TABLE operator_usage_conditions( + operator_usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT, + server_operator_id INTEGER REFERENCES server_operators(server_operator_id) ON DELETE SET NULL ON UPDATE CASCADE, + server_operator_tag TEXT, + conditions_commit TEXT NOT NULL, + accepted_at TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')) +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -890,3 +919,10 @@ CREATE INDEX idx_received_probes_group_member_id on received_probes( group_member_id ); CREATE INDEX idx_contact_requests_contact_id ON contact_requests(contact_id); +CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_conditions( + server_operator_id +); +CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions( + conditions_commit, + server_operator_id +); diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs new file mode 100644 index 0000000000..e14e95211a --- /dev/null +++ b/src/Simplex/Chat/Operators.hs @@ -0,0 +1,523 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiWayIf #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeFamilyDependencies #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Simplex.Chat.Operators where + +import Control.Applicative ((<|>)) +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson as J +import qualified Data.Aeson.Encoding as JE +import qualified Data.Aeson.TH as JQ +import Data.FileEmbed +import Data.Foldable (foldMap') +import Data.Functor.Identity +import Data.IORef +import Data.Int (Int64) +import Data.Kind +import Data.List (find, foldl') +import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe, isNothing, mapMaybe) +import Data.Scientific (floatingOrInteger) +import Data.Set (Set) +import qualified Data.Set as S +import Data.Text (Text) +import qualified Data.Text as T +import Data.Time (addUTCTime) +import Data.Time.Clock (UTCTime, nominalDay) +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +import Language.Haskell.TH.Syntax (lift) +import Simplex.Chat.Operators.Conditions +import Simplex.Chat.Types (User) +import Simplex.Chat.Types.Util (textParseJSON) +import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Protocol (AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol) +import Simplex.Messaging.Transport.Client (TransportHost (..)) +import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8) + +usageConditionsCommit :: Text +usageConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" + +previousConditionsCommit :: Text +previousConditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c" + +usageConditionsText :: Text +usageConditionsText = + $( let s = $(embedFile =<< makeRelativeToProject "PRIVACY.md") + in [|stripFrontMatter $(lift (safeDecodeUtf8 s))|] + ) + +data DBStored = DBStored | DBNew + +data SDBStored (s :: DBStored) where + SDBStored :: SDBStored 'DBStored + SDBNew :: SDBStored 'DBNew + +deriving instance Show (SDBStored s) + +class DBStoredI s where sdbStored :: SDBStored s + +instance DBStoredI 'DBStored where sdbStored = SDBStored + +instance DBStoredI 'DBNew where sdbStored = SDBNew + +data DBEntityId' (s :: DBStored) where + DBEntityId :: Int64 -> DBEntityId' 'DBStored + DBNewEntity :: DBEntityId' 'DBNew + +deriving instance Show (DBEntityId' s) + +deriving instance Eq (DBEntityId' s) + +type DBEntityId = DBEntityId' 'DBStored + +type DBNewEntity = DBEntityId' 'DBNew + +data OperatorTag = OTSimplex | OTFlux + deriving (Eq, Ord, Show) + +instance FromField OperatorTag where fromField = fromTextField_ textDecode + +instance ToField OperatorTag where toField = toField . textEncode + +instance FromJSON OperatorTag where + parseJSON = textParseJSON "OperatorTag" + +instance ToJSON OperatorTag where + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode + +instance TextEncoding OperatorTag where + textDecode = \case + "simplex" -> Just OTSimplex + "flux" -> Just OTFlux + _ -> Nothing + textEncode = \case + OTSimplex -> "simplex" + OTFlux -> "flux" + +-- this and other types only define instances of serialization for known DB IDs only, +-- entities without IDs cannot be serialized to JSON +instance FromField DBEntityId where fromField f = DBEntityId <$> fromField f + +instance ToField DBEntityId where toField (DBEntityId i) = toField i + +data UsageConditions = UsageConditions + { conditionsId :: Int64, + conditionsCommit :: Text, + notifiedAt :: Maybe UTCTime, + createdAt :: UTCTime + } + deriving (Show) + +data UsageConditionsAction + = UCAReview {operators :: [ServerOperator], deadline :: Maybe UTCTime, showNotice :: Bool} + | UCAAccepted {operators :: [ServerOperator]} + deriving (Show) + +data ServerOperatorConditions = ServerOperatorConditions + { serverOperators :: [ServerOperator], + currentConditions :: UsageConditions, + conditionsAction :: Maybe UsageConditionsAction + } + deriving (Show) + +usageConditionsAction :: [ServerOperator] -> UsageConditions -> UTCTime -> Maybe UsageConditionsAction +usageConditionsAction operators UsageConditions {createdAt, notifiedAt} now = do + let enabledOperators = filter (\ServerOperator {enabled} -> enabled) operators + if + | null enabledOperators -> Nothing + | all conditionsAccepted enabledOperators -> + let acceptedForOperators = filter conditionsAccepted operators + in Just $ UCAAccepted acceptedForOperators + | otherwise -> + let acceptForOperators = filter (not . conditionsAccepted) enabledOperators + deadline = conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) + showNotice = isNothing notifiedAt + in Just $ UCAReview acceptForOperators deadline showNotice + +conditionsRequiredOrDeadline :: UTCTime -> UTCTime -> Maybe UTCTime +conditionsRequiredOrDeadline createdAt notifiedAtOrNow = + if notifiedAtOrNow < addUTCTime (14 * nominalDay) createdAt + then Just $ conditionsDeadline notifiedAtOrNow + else Nothing -- required + where + conditionsDeadline :: UTCTime -> UTCTime + conditionsDeadline = addUTCTime (31 * nominalDay) + +data ConditionsAcceptance + = CAAccepted {acceptedAt :: Maybe UTCTime} + | CARequired {deadline :: Maybe UTCTime} + deriving (Show) + +type ServerOperator = ServerOperator' 'DBStored + +type NewServerOperator = ServerOperator' 'DBNew + +data AServerOperator = forall s. ASO (SDBStored s) (ServerOperator' s) + +deriving instance Show AServerOperator + +data ServerOperator' s = ServerOperator + { operatorId :: DBEntityId' s, + operatorTag :: Maybe OperatorTag, + tradeName :: Text, + legalName :: Maybe Text, + serverDomains :: [Text], + conditionsAcceptance :: ConditionsAcceptance, + enabled :: Bool, + smpRoles :: ServerRoles, + xftpRoles :: ServerRoles + } + deriving (Show) + +data ServerOperatorRoles = ServerOperatorRoles + { operatorId' :: Int64, + enabled' :: Bool, + smpRoles' :: ServerRoles, + xftpRoles' :: ServerRoles + } + deriving (Show) + +operatorRoles :: UserProtocol p => SProtocolType p -> ServerOperator -> ServerRoles +operatorRoles p op = case p of + SPSMP -> smpRoles op + SPXFTP -> xftpRoles op + +conditionsAccepted :: ServerOperator -> Bool +conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAcceptance of + CAAccepted {} -> True + _ -> False + +data UserOperatorServers = UserOperatorServers + { operator :: Maybe ServerOperator, + smpServers :: [UserServer 'PSMP], + xftpServers :: [UserServer 'PXFTP] + } + deriving (Show) + +data UpdatedUserOperatorServers = UpdatedUserOperatorServers + { operator :: Maybe ServerOperator, + smpServers :: [AUserServer 'PSMP], + xftpServers :: [AUserServer 'PXFTP] + } + deriving (Show) + +data ValidatedProtoServer p = ValidatedProtoServer {unVPS :: Either Text (ProtoServerWithAuth p)} + deriving (Show) + +class UserServersClass u where + type AServer u = (s :: ProtocolType -> Type) | s -> u + operator' :: u -> Maybe ServerOperator + aUserServer' :: AServer u p -> AUserServer p + servers' :: UserProtocol p => SProtocolType p -> u -> [AServer u p] + +instance UserServersClass UserOperatorServers where + type AServer UserOperatorServers = UserServer' 'DBStored + operator' UserOperatorServers {operator} = operator + aUserServer' = AUS SDBStored + servers' p UserOperatorServers {smpServers, xftpServers} = case p of + SPSMP -> smpServers + SPXFTP -> xftpServers + +instance UserServersClass UpdatedUserOperatorServers where + type AServer UpdatedUserOperatorServers = AUserServer + operator' UpdatedUserOperatorServers {operator} = operator + aUserServer' = id + servers' p UpdatedUserOperatorServers {smpServers, xftpServers} = case p of + SPSMP -> smpServers + SPXFTP -> xftpServers + +type UserServer p = UserServer' 'DBStored p + +type NewUserServer p = UserServer' 'DBNew p + +data AUserServer p = forall s. AUS (SDBStored s) (UserServer' s p) + +deriving instance Show (AUserServer p) + +data UserServer' s (p :: ProtocolType) = UserServer + { serverId :: DBEntityId' s, + server :: ProtoServerWithAuth p, + preset :: Bool, + tested :: Maybe Bool, + enabled :: Bool, + deleted :: Bool + } + deriving (Show) + +data PresetOperator = PresetOperator + { operator :: Maybe NewServerOperator, + smp :: [NewUserServer 'PSMP], + useSMP :: Int, + xftp :: [NewUserServer 'PXFTP], + useXFTP :: Int + } + deriving (Show) + +pOperator :: PresetOperator -> Maybe NewServerOperator +pOperator PresetOperator {operator} = operator + +pServers :: UserProtocol p => SProtocolType p -> PresetOperator -> [NewUserServer p] +pServers p PresetOperator {smp, xftp} = case p of + SPSMP -> smp + SPXFTP -> xftp + +operatorServersToUse :: UserProtocol p => SProtocolType p -> PresetOperator -> Int +operatorServersToUse p PresetOperator {useSMP, useXFTP} = case p of + SPSMP -> useSMP + SPXFTP -> useXFTP + +presetServer :: Bool -> ProtoServerWithAuth p -> NewUserServer p +presetServer = newUserServer_ True + +newUserServer :: ProtoServerWithAuth p -> NewUserServer p +newUserServer = newUserServer_ False True + +newUserServer_ :: Bool -> Bool -> ProtoServerWithAuth p -> NewUserServer p +newUserServer_ preset enabled server = + UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False} + +-- This function should be used inside DB transaction to update conditions in the database +-- it evaluates to (conditions to mark as accepted to SimpleX operator, current conditions, and conditions to add) +usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +usageConditionsToAdd = usageConditionsToAdd' previousConditionsCommit usageConditionsCommit + +-- This function is used in unit tests +usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case + [] + | newUser -> (Just sourceCond, sourceCond, [sourceCond]) + | otherwise -> (Just prevCond, sourceCond, [prevCond, sourceCond]) + where + prevCond = conditions 1 prevCommit + sourceCond = conditions 2 sourceCommit + conds + | hasSourceCond -> (Nothing, last conds, []) + | otherwise -> (Nothing, sourceCond, [sourceCond]) + where + hasSourceCond = any ((sourceCommit ==) . conditionsCommit) conds + sourceCond = conditions cId sourceCommit + cId = maximum (map conditionsId conds) + 1 + where + conditions cId commit = UsageConditions {conditionsId = cId, conditionsCommit = commit, notifiedAt = Nothing, createdAt} + +presetUserServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [UpdatedUserOperatorServers] +presetUserServers = mapMaybe $ \(presetOp_, op) -> mkUS op <$> presetOp_ + where + mkUS op PresetOperator {smp, xftp} = + UpdatedUserOperatorServers op (map (AUS SDBNew) smp) (map (AUS SDBNew) xftp) + +-- This function should be used inside DB transaction to update operators. +-- It allows to add/remove/update preset operators in the database preserving enabled and roles settings, +-- and preserves custom operators without tags for forward compatibility. +updatedServerOperators :: NonEmpty PresetOperator -> [ServerOperator] -> [(Maybe PresetOperator, Maybe AServerOperator)] +updatedServerOperators presetOps storedOps = + foldr addPreset [] presetOps + <> map (\op -> (Nothing, Just $ ASO SDBStored op)) (filter (isNothing . operatorTag) storedOps) + where + -- TODO remove domains of preset operators from custom + addPreset op = ((Just op, storedOp' <$> pOperator op) :) + where + storedOp' presetOp = case find ((operatorTag presetOp ==) . operatorTag) storedOps of + Just ServerOperator {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles} -> + ASO SDBStored presetOp {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles} + Nothing -> ASO SDBNew presetOp + +-- This function should be used inside DB transaction to update servers. +updatedUserServers :: (Maybe PresetOperator, UserOperatorServers) -> UpdatedUserOperatorServers +updatedUserServers (presetOp_, UserOperatorServers {operator, smpServers, xftpServers}) = + UpdatedUserOperatorServers {operator, smpServers = smp', xftpServers = xftp'} + where + stored = map (AUS SDBStored) + (smp', xftp') = case presetOp_ of + Nothing -> (stored smpServers, stored xftpServers) + Just presetOp -> (updated SPSMP smpServers, updated SPXFTP xftpServers) + where + updated :: forall p. UserProtocol p => SProtocolType p -> [UserServer p] -> [AUserServer p] + updated p srvs = map userServer presetSrvs <> stored (filter customServer srvs) + where + storedSrvs :: Map (ProtoServerWithAuth p) (UserServer p) + storedSrvs = foldl' (\ss srv@UserServer {server} -> M.insert server srv ss) M.empty srvs + customServer :: UserServer p -> Bool + customServer srv = not (preset srv) && all (`S.notMember` presetHosts) (srvHost srv) + presetSrvs :: [NewUserServer p] + presetSrvs = pServers p presetOp + presetHosts :: Set TransportHost + presetHosts = foldMap' (S.fromList . L.toList . srvHost) presetSrvs + userServer :: NewUserServer p -> AUserServer p + userServer srv@UserServer {server} = maybe (AUS SDBNew srv) (AUS SDBStored) (M.lookup server storedSrvs) + +srvHost :: UserServer' s p -> NonEmpty TransportHost +srvHost UserServer {server = ProtoServerWithAuth srv _} = host srv + +agentServerCfgs :: UserProtocol p => SProtocolType p -> [(Text, ServerOperator)] -> [UserServer' s p] -> [ServerCfg p] +agentServerCfgs p opDomains = mapMaybe agentServer + where + agentServer :: UserServer' s p -> Maybe (ServerCfg p) + agentServer srv@UserServer {server, enabled} = + case find (\(d, _) -> any (matchingHost d) (srvHost srv)) opDomains of + Just (_, op@ServerOperator {operatorId = DBEntityId opId, enabled = opEnabled}) + | opEnabled -> Just ServerCfg {server, enabled, operator = Just opId, roles = operatorRoles p op} + | otherwise -> Nothing + Nothing -> + Just ServerCfg {server, enabled, operator = Nothing, roles = allRoles} + +matchingHost :: Text -> TransportHost -> Bool +matchingHost d = \case + THDomainName h -> d `T.isSuffixOf` T.pack h + _ -> False + +operatorDomains :: [ServerOperator' s] -> [(Text, ServerOperator' s)] +operatorDomains = foldr (\op ds -> foldr (\d -> ((d, op) :)) ds (serverDomains op)) [] + +class Box b where + box :: a -> b a + unbox :: b a -> a + +instance Box Identity where + box = Identity + unbox = runIdentity + +instance Box ((,) (Maybe a)) where + box = (Nothing,) + unbox = snd + +groupByOperator :: ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [UserOperatorServers] +groupByOperator (ops, smpSrvs, xftpSrvs) = map runIdentity <$> groupByOperator_ (map Identity ops, smpSrvs, xftpSrvs) + +-- For the initial app start this function relies on tuple being Functor/Box +-- to preserve the information about operator being DBNew or DBStored +groupByOperator' :: ([(Maybe PresetOperator, Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [(Maybe PresetOperator, UserOperatorServers)] +groupByOperator' = groupByOperator_ +{-# INLINE groupByOperator' #-} + +groupByOperator_ :: forall f. (Box f, Traversable f) => ([f (Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [f UserOperatorServers] +groupByOperator_ (ops, smpSrvs, xftpSrvs) = do + let ops' = mapMaybe sequence ops + customOp_ = find (isNothing . unbox) ops + ss <- mapM ((\op -> (serverDomains (unbox op),) <$> newIORef (mkUS . Just <$> op))) ops' + custom <- newIORef $ maybe (box $ mkUS Nothing) (mkUS <$>) customOp_ + mapM_ (addServer ss custom addSMP) (reverse smpSrvs) + mapM_ (addServer ss custom addXFTP) (reverse xftpSrvs) + opSrvs <- mapM (readIORef . snd) ss + customSrvs <- readIORef custom + pure $ opSrvs <> [customSrvs] + where + mkUS op = UserOperatorServers op [] [] + addServer :: [([Text], IORef (f UserOperatorServers))] -> IORef (f UserOperatorServers) -> (UserServer p -> UserOperatorServers -> UserOperatorServers) -> UserServer p -> IO () + addServer ss custom add srv = + let v = maybe custom snd $ find (\(ds, _) -> any (\d -> any (matchingHost d) (srvHost srv)) ds) ss + in atomicModifyIORef'_ v (add srv <$>) + addSMP srv s@UserOperatorServers {smpServers} = (s :: UserOperatorServers) {smpServers = srv : smpServers} + addXFTP srv s@UserOperatorServers {xftpServers} = (s :: UserOperatorServers) {xftpServers = srv : xftpServers} + +data UserServersError + = USENoServers {protocol :: AProtocolType, user :: Maybe User} + | USEStorageMissing {protocol :: AProtocolType, user :: Maybe User} + | USEProxyMissing {protocol :: AProtocolType, user :: Maybe User} + | USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: Text, duplicateHost :: TransportHost} + deriving (Show) + +validateUserServers :: UserServersClass u' => [u'] -> [(User, [UserOperatorServers])] -> [UserServersError] +validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others + where + currUserErrs = noServersErrs SPSMP Nothing curr <> noServersErrs SPXFTP Nothing curr <> serverErrs SPSMP curr <> serverErrs SPXFTP curr + otherUserErrs (user, uss) = noServersErrs SPSMP (Just user) uss <> noServersErrs SPXFTP (Just user) uss + noServersErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> Maybe User -> [u] -> [UserServersError] + noServersErrs p user uss + | noServers opEnabled = [USENoServers p' user] + | otherwise = [USEStorageMissing p' user | noServers (hasRole storage)] <> [USEProxyMissing p' user | noServers (hasRole proxy)] + where + p' = AProtocolType p + noServers cond = not $ any srvEnabled $ userServers p $ filter cond uss + opEnabled = maybe True (\ServerOperator {enabled} -> enabled) . operator' + hasRole roleSel = maybe True (\op@ServerOperator {enabled} -> enabled && roleSel (operatorRoles p op)) . operator' + srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted + serverErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [u] -> [UserServersError] + serverErrs p uss = mapMaybe duplicateErr_ srvs + where + p' = AProtocolType p + srvs = filter (\(AUS _ UserServer {deleted}) -> not deleted) $ userServers p uss + duplicateErr_ (AUS _ srv@UserServer {server}) = + USEDuplicateServer p' (safeDecodeUtf8 $ strEncode server) + <$> find (`S.member` duplicateHosts) (srvHost srv) + duplicateHosts = snd $ foldl' addHost (S.empty, S.empty) allHosts + allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs + addHost (hs, dups) h + | h `S.member` hs = (hs, S.insert h dups) + | otherwise = (S.insert h hs, dups) + userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p] + userServers p = map aUserServer' . concatMap (servers' p) + +instance ToJSON (DBEntityId' s) where + toEncoding = \case + DBEntityId i -> toEncoding i + DBNewEntity -> JE.null_ + toJSON = \case + DBEntityId i -> toJSON i + DBNewEntity -> J.Null + +instance DBStoredI s => FromJSON (DBEntityId' s) where + parseJSON v = case (v, sdbStored @s) of + (J.Null, SDBNew) -> pure DBNewEntity + (J.Number n, SDBStored) -> case floatingOrInteger n of + Left (_ :: Double) -> fail "bad DBEntityId" + Right i -> pure $ DBEntityId (fromInteger i) + _ -> fail "bad DBEntityId" + omittedField = case sdbStored @s of + SDBStored -> Nothing + SDBNew -> Just DBNewEntity + +$(JQ.deriveJSON defaultJSON ''UsageConditions) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CA") ''ConditionsAcceptance) + +instance ToJSON (ServerOperator' s) where + toEncoding = $(JQ.mkToEncoding defaultJSON ''ServerOperator') + toJSON = $(JQ.mkToJSON defaultJSON ''ServerOperator') + +instance DBStoredI s => FromJSON (ServerOperator' s) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ServerOperator') + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction) + +$(JQ.deriveJSON defaultJSON ''ServerOperatorConditions) + +instance ProtocolTypeI p => ToJSON (UserServer' s p) where + toEncoding = $(JQ.mkToEncoding defaultJSON ''UserServer') + toJSON = $(JQ.mkToJSON defaultJSON ''UserServer') + +instance (DBStoredI s, ProtocolTypeI p) => FromJSON (UserServer' s p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''UserServer') + +instance ProtocolTypeI p => FromJSON (AUserServer p) where + parseJSON v = (AUS SDBStored <$> parseJSON v) <|> (AUS SDBNew <$> parseJSON v) + +$(JQ.deriveJSON defaultJSON ''UserOperatorServers) + +instance FromJSON UpdatedUserOperatorServers where + parseJSON = $(JQ.mkParseJSON defaultJSON ''UpdatedUserOperatorServers) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError) diff --git a/src/Simplex/Chat/Operators/Conditions.hs b/src/Simplex/Chat/Operators/Conditions.hs new file mode 100644 index 0000000000..a314c1901a --- /dev/null +++ b/src/Simplex/Chat/Operators/Conditions.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Operators.Conditions where + +import Data.Char (isSpace) +import Data.Text (Text) +import qualified Data.Text as T + +stripFrontMatter :: Text -> Text +stripFrontMatter = + T.unlines + -- . dropWhile ("# " `T.isPrefixOf`) -- strip title + . dropWhile (T.all isSpace) + . dropWhile fm + . (\ls -> let ls' = dropWhile (not . fm) ls in if null ls' then ls else ls') + . dropWhile fm + . T.lines + where + fm = ("---" `T.isPrefixOf`) diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 16ffe6e28f..f398831194 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -236,7 +236,7 @@ coreChatOptsP appDir defaultDbFileName = do ) yesToUpMigrations <- switch - ( long "--yes-migrate" + ( long "yes-migrate" <> short 'y' <> help "Automatically confirm \"up\" database migrations" ) diff --git a/src/Simplex/Chat/Stats.hs b/src/Simplex/Chat/Stats.hs index 6dd5c79ab1..21ad25b311 100644 --- a/src/Simplex/Chat/Stats.hs +++ b/src/Simplex/Chat/Stats.hs @@ -7,7 +7,6 @@ module Simplex.Chat.Stats where import qualified Data.Aeson.TH as J import Data.List (partition) -import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust) @@ -131,7 +130,7 @@ data NtfServerSummary = NtfServerSummary -- - users are passed to exclude hidden users from totalServersSummary; -- - if currentUser is hidden, it should be accounted in totalServersSummary; -- - known is set only in user level summaries based on passed userSMPSrvs and userXFTPSrvs -toPresentedServersSummary :: AgentServersSummary -> [User] -> User -> NonEmpty SMPServer -> NonEmpty XFTPServer -> [NtfServer] -> PresentedServersSummary +toPresentedServersSummary :: AgentServersSummary -> [User] -> User -> [SMPServer] -> [XFTPServer] -> [NtfServer] -> PresentedServersSummary toPresentedServersSummary agentSummary users currentUser userSMPSrvs userXFTPSrvs userNtfSrvs = do let (userSMPSrvsSumms, allSMPSrvsSumms) = accSMPSrvsSummaries (userSMPCurr, userSMPPrev, userSMPProx) = smpSummsIntoCategories userSMPSrvsSumms diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 2444078a33..7218706239 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -115,6 +115,7 @@ import Simplex.Chat.Migrations.M20240920_user_order import Simplex.Chat.Migrations.M20241008_indexes import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id import Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id +import Simplex.Chat.Migrations.M20241027_server_operators import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -229,7 +230,8 @@ schemaMigrations = ("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order), ("20241008_indexes", m20241008_indexes, Just down_m20241008_indexes), ("20241010_contact_requests_contact_id", m20241010_contact_requests_contact_id, Just down_m20241010_contact_requests_contact_id), - ("20241023_chat_item_autoincrement_id", m20241023_chat_item_autoincrement_id, Just down_m20241023_chat_item_autoincrement_id) + ("20241023_chat_item_autoincrement_id", m20241023_chat_item_autoincrement_id, Just down_m20241023_chat_item_autoincrement_id), + ("20241027_server_operators", m20241027_server_operators, Just down_m20241027_server_operators) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index fb9774a54e..ec657fd6f7 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -1,5 +1,8 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} @@ -47,7 +50,17 @@ module Simplex.Chat.Store.Profiles getContactWithoutConnViaAddress, updateUserAddressAutoAccept, getProtocolServers, - overwriteProtocolServers, + insertProtocolServer, + getUpdateServerOperators, + getServerOperators, + getUserServers, + setServerOperators, + getCurrentUsageConditions, + getLatestAcceptedConditions, + setConditionsNotified, + acceptConditions, + setUserServers, + setUserServers', createCall, deleteCalls, getCalls, @@ -68,14 +81,16 @@ import Data.Functor (($>)) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L -import Data.Maybe (fromMaybe) +import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) +import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) +import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Call import Simplex.Chat.Messages +import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared @@ -83,7 +98,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..)) +import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..)) import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB @@ -91,7 +106,7 @@ import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON) -import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) +import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) @@ -515,42 +530,285 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply) _ -> (False, False, Nothing) -getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> IO [ServerCfg p] -getProtocolServers db User {userId} = - map toServerCfg +getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> IO [UserServer p] +getProtocolServers db p User {userId} = + map toUserServer <$> DB.query db [sql| - SELECT host, port, key_hash, basic_auth, preset, tested, enabled + SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled FROM protocol_servers - WHERE user_id = ? AND protocol = ?; + WHERE user_id = ? AND protocol = ? |] - (userId, decodeLatin1 $ strEncode protocol) + (userId, decodeLatin1 $ strEncode p) where - protocol = protocolTypeI @p - toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p - toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) = - let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) - in ServerCfg {server, preset, tested, enabled} + toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> UserServer p + toUserServer (serverId, host, port, keyHash, auth_, preset, tested, enabled) = + let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) + in UserServer {serverId, server, preset, tested, enabled, deleted = False} -overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () -overwriteProtocolServers db User {userId} servers = - checkConstraint SEUniqueID . ExceptT $ do - currentTs <- getCurrentTime - DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND protocol = ? " (userId, protocol) - forM_ servers $ \ServerCfg {server, preset, tested, enabled} -> do - let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server +insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p) +insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled} = do + DB.execute + db + [sql| + INSERT INTO protocol_servers + (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + |] + (serverColumns p server :. (preset, tested, enabled, userId, ts, ts)) + sId <- insertedRowId db + pure (srv :: NewUserServer p) {serverId = DBEntityId sId} + +updateProtocolServer :: ProtocolTypeI p => DB.Connection -> SProtocolType p -> UTCTime -> UserServer p -> IO () +updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabled} = + DB.execute + db + [sql| + UPDATE protocol_servers + SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?, + preset = ?, tested = ?, enabled = ?, updated_at = ? + WHERE smp_server_id = ? + |] + (serverColumns p server :. (preset, tested, enabled, ts, serverId)) + +serverColumns :: ProtocolTypeI p => SProtocolType p -> ProtoServerWithAuth p -> (Text, NonEmpty TransportHost, String, C.KeyHash, Maybe Text) +serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) = + let protocol = decodeLatin1 $ strEncode p + auth = safeDecodeUtf8 . unBasicAuth <$> auth_ + in (protocol, host, port, keyHash, auth) + +getServerOperators :: DB.Connection -> ExceptT StoreError IO ServerOperatorConditions +getServerOperators db = do + currentConditions <- getCurrentUsageConditions db + liftIO $ do + now <- getCurrentTime + latestAcceptedConds_ <- getLatestAcceptedConditions db + let getConds op = (\ca -> op {conditionsAcceptance = ca}) <$> getOperatorConditions_ db op currentConditions latestAcceptedConds_ now + ops <- mapM getConds =<< getServerOperators_ db + let conditionsAction = usageConditionsAction ops currentConditions now + pure ServerOperatorConditions {serverOperators = ops, currentConditions, conditionsAction} + +getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) +getUserServers db user = + (,,) + <$> (map Just . serverOperators <$> getServerOperators db) + <*> liftIO (getProtocolServers db SPSMP user) + <*> liftIO (getProtocolServers db SPXFTP user) + +setServerOperators :: DB.Connection -> NonEmpty ServerOperator -> IO () +setServerOperators db ops = do + currentTs <- getCurrentTime + mapM_ (updateServerOperator db currentTs) ops + +updateServerOperator :: DB.Connection -> UTCTime -> ServerOperator -> IO () +updateServerOperator db currentTs ServerOperator {operatorId, enabled, smpRoles, xftpRoles} = + DB.execute + db + [sql| + UPDATE server_operators + SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ? + WHERE server_operator_id = ? + |] + (enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, currentTs, operatorId) + +getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [(Maybe PresetOperator, Maybe ServerOperator)] +getUpdateServerOperators db presetOps newUser = do + conds <- map toUsageConditions <$> DB.query_ db usageCondsQuery + now <- getCurrentTime + let (acceptForSimplex_, currentConds, condsToAdd) = usageConditionsToAdd newUser now conds + mapM_ insertConditions condsToAdd + latestAcceptedConds_ <- getLatestAcceptedConditions db + ops <- updatedServerOperators presetOps <$> getServerOperators_ db + forM ops $ traverse $ mapM $ \(ASO _ op) -> -- traverse for tuple, mapM for Maybe + case operatorId op of + DBNewEntity -> do + op' <- insertOperator op + case (operatorTag op', acceptForSimplex_) of + (Just OTSimplex, Just cond) -> autoAcceptConditions op' cond + _ -> pure op' + DBEntityId _ -> do + updateOperator op + getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case + CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds + CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds + ca -> pure op {conditionsAcceptance = ca} + where + insertConditions UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} = DB.execute db [sql| - INSERT INTO protocol_servers - (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?) + INSERT INTO usage_conditions + (usage_conditions_id, conditions_commit, notified_at, created_at) + VALUES (?,?,?,?) |] - ((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, enabled, userId, currentTs, currentTs)) - pure $ Right () + (conditionsId, conditionsCommit, notifiedAt, createdAt) + updateOperator :: ServerOperator -> IO () + updateOperator ServerOperator {operatorId, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} = + DB.execute + db + [sql| + UPDATE server_operators + SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ? + WHERE server_operator_id = ? + |] + (tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, operatorId) + insertOperator :: NewServerOperator -> IO ServerOperator + insertOperator op@ServerOperator {operatorTag, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} = do + DB.execute + db + [sql| + INSERT INTO server_operators + (server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy) + VALUES (?,?,?,?,?,?,?,?,?) + |] + (operatorTag, tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles) + opId <- insertedRowId db + pure op {operatorId = DBEntityId opId} + autoAcceptConditions op UsageConditions {conditionsCommit} = + acceptConditions_ db op conditionsCommit Nothing + $> op {conditionsAcceptance = CAAccepted Nothing} + +serverOperatorQuery :: Query +serverOperatorQuery = + [sql| + SELECT server_operator_id, server_operator_tag, trade_name, legal_name, + server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy + FROM server_operators + |] + +getServerOperators_ :: DB.Connection -> IO [ServerOperator] +getServerOperators_ db = map toServerOperator <$> DB.query_ db serverOperatorQuery + +toServerOperator :: (DBEntityId, Maybe OperatorTag, Text, Maybe Text, Text, Bool) :. (Bool, Bool) :. (Bool, Bool) -> ServerOperator +toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, enabled) :. smpRoles' :. xftpRoles') = + ServerOperator + { operatorId, + operatorTag, + tradeName, + legalName, + serverDomains = T.splitOn "," domains, + conditionsAcceptance = CARequired Nothing, + enabled, + smpRoles = serverRoles smpRoles', + xftpRoles = serverRoles xftpRoles' + } where - protocol = decodeLatin1 $ strEncode $ protocolTypeI @p + serverRoles (storage, proxy) = ServerRoles {storage, proxy} + +getOperatorConditions_ :: DB.Connection -> ServerOperator -> UsageConditions -> Maybe UsageConditions -> UTCTime -> IO ConditionsAcceptance +getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {conditionsCommit = currentCommit, createdAt, notifiedAt} latestAcceptedConds_ now = do + case latestAcceptedConds_ of + Nothing -> pure $ CARequired Nothing -- no conditions accepted by any operator + Just UsageConditions {conditionsCommit = latestAcceptedCommit} -> do + operatorAcceptedConds_ <- + maybeFirstRow id $ + DB.query + db + [sql| + SELECT conditions_commit, accepted_at + FROM operator_usage_conditions + WHERE server_operator_id = ? + ORDER BY operator_usage_conditions_id DESC + LIMIT 1 + |] + (Only operatorId) + pure $ case operatorAcceptedConds_ of + Just (operatorCommit, acceptedAt_) + | operatorCommit /= latestAcceptedCommit -> CARequired Nothing -- TODO should we consider this operator disabled? + | currentCommit /= latestAcceptedCommit -> CARequired $ conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) + | otherwise -> CAAccepted acceptedAt_ + _ -> CARequired Nothing -- no conditions were accepted for this operator + +getCurrentUsageConditions :: DB.Connection -> ExceptT StoreError IO UsageConditions +getCurrentUsageConditions db = + ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $ + DB.query_ db (usageCondsQuery <> " DESC LIMIT 1") + +usageCondsQuery :: Query +usageCondsQuery = + [sql| + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + ORDER BY usage_conditions_id + |] + +toUsageConditions :: (Int64, Text, Maybe UTCTime, UTCTime) -> UsageConditions +toUsageConditions (conditionsId, conditionsCommit, notifiedAt, createdAt) = + UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} + +getLatestAcceptedConditions :: DB.Connection -> IO (Maybe UsageConditions) +getLatestAcceptedConditions db = + maybeFirstRow toUsageConditions $ + DB.query_ + db + [sql| + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + WHERE conditions_commit = ( + SELECT conditions_commit + FROM operator_usage_conditions + ORDER BY accepted_at DESC + LIMIT 1 + ) + |] + +setConditionsNotified :: DB.Connection -> Int64 -> UTCTime -> IO () +setConditionsNotified db condId notifiedAt = + DB.execute db "UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ?" (notifiedAt, condId) + +acceptConditions :: DB.Connection -> Int64 -> NonEmpty Int64 -> UTCTime -> ExceptT StoreError IO () +acceptConditions db condId opIds acceptedAt = do + UsageConditions {conditionsCommit} <- getUsageConditionsById_ db condId + operators <- mapM getServerOperator_ opIds + let ts = Just acceptedAt + liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit ts + where + getServerOperator_ opId = + ExceptT $ firstRow toServerOperator (SEOperatorNotFound opId) $ + DB.query db (serverOperatorQuery <> " WHERE server_operator_id = ?") (Only opId) + +acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> Maybe UTCTime -> IO () +acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt = + DB.execute + db + [sql| + INSERT INTO operator_usage_conditions + (server_operator_id, server_operator_tag, conditions_commit, accepted_at) + VALUES (?,?,?,?) + |] + (operatorId, operatorTag, conditionsCommit, acceptedAt) + +getUsageConditionsById_ :: DB.Connection -> Int64 -> ExceptT StoreError IO UsageConditions +getUsageConditionsById_ db conditionsId = + ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $ + DB.query + db + [sql| + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + WHERE usage_conditions_id = ? + |] + (Only conditionsId) + +setUserServers :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> ExceptT StoreError IO UserOperatorServers +setUserServers db user ts = checkConstraint SEUniqueID . liftIO . setUserServers' db user ts + +setUserServers' :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> IO UserOperatorServers +setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, smpServers, xftpServers} = do + mapM_ (updateServerOperator db ts) operator + smpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPSMP) smpServers + xftpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPXFTP) xftpServers + pure UserOperatorServers {operator, smpServers = smpSrvs', xftpServers = xftpSrvs'} + where + upsertOrDelete :: ProtocolTypeI p => SProtocolType p -> AUserServer p -> IO (Maybe (UserServer p)) + upsertOrDelete p (AUS _ s@UserServer {serverId, deleted}) = case serverId of + DBNewEntity + | deleted -> pure Nothing + | otherwise -> Just <$> insertProtocolServer db p user ts s + DBEntityId srvId + | deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, False) + | otherwise -> Just s <$ updateProtocolServer db p ts s createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index f9a8685ec8..fcd9896917 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -127,6 +127,8 @@ data StoreError | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} | SERemoteCtrlDuplicateCA | SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId} + | SEOperatorNotFound {serverOperatorId :: Int64} + | SEUsageConditionsNotFound deriving (Show, Exception) $(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 5cc695db04..aa6babfcbd 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Terminal where @@ -13,15 +14,15 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB -import Simplex.Chat (defaultChatConfig) +import Simplex.Chat (_defaultNtfServers, defaultChatConfig, operatorSimpleXChat) import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) +import Simplex.Chat.Operators import Simplex.Chat.Options import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) -import Simplex.Messaging.Agent.Env.SQLite (presetServerCfg) import Simplex.Messaging.Client (NetworkConfig (..), SMPProxyFallback (..), SMPProxyMode (..), defaultNetworkConfig) import Simplex.Messaging.Util (raceAny_) import System.IO (hFlush, hSetEcho, stdin, stdout) @@ -29,20 +30,24 @@ import System.IO (hFlush, hSetEcho, stdin, stdout) terminalChatConfig :: ChatConfig terminalChatConfig = defaultChatConfig - { defaultServers = - DefaultAgentServers - { smp = - L.fromList $ - map - (presetServerCfg True) - [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", - "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", - "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" - ], - useSMP = 3, - ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"], - xftp = L.map (presetServerCfg True) defaultXFTPServers, - useXFTP = L.length defaultXFTPServers, + { presetServers = + PresetServers + { operators = + [ PresetOperator + { operator = Just operatorSimpleXChat, + smp = + map + (presetServer True) + [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", + "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", + "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" + ], + useSMP = 3, + xftp = map (presetServer True) $ L.toList defaultXFTPServers, + useXFTP = 3 + } + ], + ntf = _defaultNtfServers, netCfg = defaultNetworkConfig { smpProxyMode = SPMUnknown, diff --git a/src/Simplex/Chat/Terminal/Main.hs b/src/Simplex/Chat/Terminal/Main.hs index 64703a3a92..b0eb4dac88 100644 --- a/src/Simplex/Chat/Terminal/Main.hs +++ b/src/Simplex/Chat/Terminal/Main.hs @@ -10,7 +10,7 @@ import Data.Maybe (fromMaybe) import Data.Time.Clock (getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) import Network.Socket -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), DefaultAgentServers (DefaultAgentServers, netCfg), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Terminal @@ -56,7 +56,7 @@ simplexChatCLI' cfg opts@ChatOpts {chatCmd, chatCmdLog, chatCmdDelay, chatServer putStrLn $ serializeChatResponse (rh, Just user) ts tz rh r welcome :: ChatConfig -> ChatOpts -> IO () -welcome ChatConfig {defaultServers = DefaultAgentServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} = +welcome ChatConfig {presetServers = PresetServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} = mapM_ putStrLn [ versionString versionNumber, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8ae74e8961..f9ec3f936c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -25,6 +25,7 @@ import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe) +import Data.String import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) @@ -42,6 +43,7 @@ import Simplex.Chat.Help import Simplex.Chat.Markdown import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange) import Simplex.Chat.Remote.Types @@ -53,7 +55,7 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) -import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerCfg (..)) +import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerRoles (..)) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..), SocksMode (..)) @@ -63,7 +65,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..)) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) @@ -95,8 +97,11 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRChats chats -> viewChats ts tz chats CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] CRApiParsedMarkdown ft -> [viewJSON ft] - CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure + CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca + CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp) + CRUserServersValidation {} -> [] + CRUsageConditions current _ accepted_ -> viewUsageConditions current accepted_ CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl CRNetworkConfig cfg -> viewNetworkConfig cfg CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile @@ -1209,27 +1214,43 @@ viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', sho "profile is " <> if isJust viewPwdHash then "hidden" else "visible" ] -viewUserServers :: AUserProtoServers -> Bool -> [StyledString] -viewUserServers (AUPS UserProtoServers {serverProtocol = p, protoServers, presetServers}) testView = - customServers - <> if testView - then [] - else - [ "", - "use " <> highlight (srvCmd <> " test ") <> " to test " <> pName <> " server connection", - "use " <> highlight (srvCmd <> " ") <> " to configure " <> pName <> " servers", - "use " <> highlight (srvCmd <> " default") <> " to remove configured " <> pName <> " servers and use presets" - ] - <> case p of - SPSMP -> ["(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)"] - SPXFTP -> ["(chat option " <> highlight' "-xftp-servers" <> " has precedence over saved XFTP servers for chat session)"] +viewUserServers :: UserOperatorServers -> [StyledString] +viewUserServers (UserOperatorServers _ [] []) = [] +viewUserServers UserOperatorServers {operator, smpServers, xftpServers} = + [plain $ maybe "Your servers" shortViewOperator operator] + <> viewServers SPSMP smpServers + <> viewServers SPXFTP xftpServers where - srvCmd = "/" <> strEncode p - pName = protocolName p - customServers = - if null protoServers - then ("no " <> pName <> " servers saved, using presets: ") : viewServers presetServers - else viewServers protoServers + viewServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [UserServer p] -> [StyledString] + viewServers _ [] = [] + viewServers p srvs + | maybe True (\ServerOperator {enabled} -> enabled) operator = + [" " <> protocolName p <> " servers" <> maybe "" ((" " <>) . viewRoles) operator] + <> map (plain . (" " <> ) . viewServer) srvs + | otherwise = [] + where + viewServer UserServer {server, preset, tested, enabled} = safeDecodeUtf8 (strEncode server) <> serverInfo + where + serverInfo = if null serverInfo_ then "" else parens $ T.intercalate ", " serverInfo_ + serverInfo_ = ["preset" | preset] <> testedInfo <> ["disabled" | not enabled] + testedInfo = maybe [] (\t -> ["test: " <> if t then "passed" else "failed"]) tested + viewRoles op@ServerOperator {enabled} + | not enabled = "disabled" + | storage rs && proxy rs = "enabled" + | storage rs = "enabled storage" + | proxy rs = "enabled proxy" + | otherwise = "disabled (servers known)" + where + rs = operatorRoles p op + +serversUserHelp :: [StyledString] +serversUserHelp = + [ "", + "use " <> highlight' "/smp test " <> " to test SMP server connection", + "use " <> highlight' "/smp " <> " to configure SMP servers", + "or the same commands starting from /xftp for XFTP servers", + "chat options " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") and " <> highlight' "--xftp-servers" <> " have precedence over preset servers for new user profiles" + ] protocolName :: ProtocolTypeI p => SProtocolType p -> StyledString protocolName = plain . map toUpper . T.unpack . decodeLatin1 . strEncode @@ -1250,6 +1271,68 @@ viewServerTestResult (AProtoServerWithAuth p _) = \case where pName = protocolName p +viewServerOperators :: [ServerOperator] -> Maybe UsageConditionsAction -> [StyledString] +viewServerOperators ops ca = map (plain . viewOperator) ops <> maybe [] viewConditionsAction ca + +viewOperator :: ServerOperator' s -> Text +viewOperator op@ServerOperator {tradeName, legalName, serverDomains, conditionsAcceptance} = + viewOpIdTag op + <> tradeName + <> maybe "" parens legalName + <> (", domains: " <> T.intercalate ", " serverDomains) + <> (", servers: " <> viewOpEnabled op) + <> (", conditions: " <> viewOpConditions conditionsAcceptance) + +shortViewOperator :: ServerOperator -> Text +shortViewOperator ServerOperator {operatorId = DBEntityId opId, tradeName, enabled} = + tshow opId <> ". " <> tradeName <> parens (if enabled then "enabled" else "disabled") + +viewOpIdTag :: ServerOperator' s -> Text +viewOpIdTag ServerOperator {operatorId, operatorTag} = case operatorId of + DBEntityId i -> tshow i <> tag + DBNewEntity -> tag + where + tag = maybe "" (parens . textEncode) operatorTag <> ". " + +viewOpConditions :: ConditionsAcceptance -> Text +viewOpConditions = \case + CAAccepted ts -> viewCond "accepted" ts + CARequired ts -> viewCond "required" ts + where + viewCond w ts = w <> maybe "" (parens . tshow) ts + +viewOpEnabled :: ServerOperator' s -> Text +viewOpEnabled ServerOperator {enabled, smpRoles, xftpRoles} + | not enabled = "disabled" + | no smpRoles && no xftpRoles = "disabled (servers known)" + | both smpRoles && both xftpRoles = "enabled" + | otherwise = "SMP " <> viewRoles smpRoles <> ", XFTP " <> viewRoles xftpRoles + where + no rs = not $ storage rs || proxy rs + both rs = storage rs && proxy rs + viewRoles rs + | both rs = "enabled" + | storage rs = "enabled storage" + | proxy rs = "enabled proxy" + | otherwise = "disabled (servers known)" + +viewConditionsAction :: UsageConditionsAction -> [StyledString] +viewConditionsAction = \case + UCAReview {operators, deadline, showNotice} | showNotice -> case deadline of + Just ts -> [plain $ "The new conditions will be accepted for " <> ops <> " at " <> tshow ts] + Nothing -> [plain $ "The new conditions have to be accepted for " <> ops] + where + ops = T.intercalate ", " $ map legalName_ operators + legalName_ ServerOperator {tradeName, legalName} = fromMaybe tradeName legalName + _ -> [] + +viewUsageConditions :: UsageConditions -> Maybe UsageConditions -> [StyledString] +viewUsageConditions current accepted_ = + [plain $ "Current conditions: " <> viewConds current <> maybe "" (\ac -> ", accepted conditions: " <> viewConds ac) accepted_] + where + viewConds UsageConditions {conditionsId, conditionsCommit, notifiedAt} = + tshow conditionsId <> maybe "" (const " (notified)") notifiedAt <> ". " <> conditionsCommit + viewChatItemTTL :: Maybe Int64 -> [StyledString] viewChatItemTTL = \case Nothing -> ["old messages are not being deleted"] @@ -1326,9 +1409,6 @@ viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} = ["receiving messages via: " <> viewRcvQueuesInfo rcvQueuesInfo | not $ null rcvQueuesInfo] <> ["sending messages via: " <> viewSndQueuesInfo sndQueuesInfo | not $ null sndQueuesInfo] -viewServers :: ProtocolTypeI p => NonEmpty (ServerCfg p) -> [StyledString] -viewServers = map (plain . B.unpack . strEncode . (\ServerCfg {server} -> server)) . L.toList - viewRcvQueuesInfo :: [RcvQueueInfo] -> StyledString viewRcvQueuesInfo = plain . intercalate ", " . map showQueueInfo where @@ -1926,7 +2006,9 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo then [versionString version, updateStr, "simplexmq: " <> simplexmqVersion <> parens simplexmqCommit] else [versionString version, updateStr] where - parens s = " (" <> s <> ")" + +parens :: (IsString a, Semigroup a) => a -> a +parens s = " (" <> s <> ")" viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] viewRemoteHosts = \case diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 73f3e350df..8b7e8fcd32 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -376,6 +376,16 @@ userName :: TestCC -> IO [Char] userName (TestCC ChatController {currentUser} _ _ _ _ _) = maybe "no current user" (\User {localDisplayName} -> T.unpack localDisplayName) <$> readTVarIO currentUser +testChat :: HasCallStack => Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO () +testChat = testChatCfgOpts testCfg testOpts + +testChatCfgOpts :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO () +testChatCfgOpts cfg opts p test = testChatN cfg opts [p] test_ + where + test_ :: HasCallStack => [TestCC] -> IO () + test_ [tc] = test tc + test_ _ = error "expected 1 chat client" + testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () testChat2 = testChatCfgOpts2 testCfg testOpts @@ -423,11 +433,10 @@ smpServerCfg = ServerConfig { transports = [(serverPort, transport @TLS, False)], tbqSize = 1, - -- serverTbqSize = 1, - msgQueueQuota = 16, msgStoreType = AMSType SMSMemory, - maxJournalMsgCount = 1000, - maxJournalStateLines = 1000, + msgQueueQuota = 16, + maxJournalMsgCount = 24, + maxJournalStateLines = 4, queueIdBytes = 12, msgIdBytes = 6, storeLogFile = Nothing, diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 8756657e59..d305055d94 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -25,7 +25,7 @@ import Database.SQLite.Simple (Only (..)) import Simplex.Chat.AppSettings (defaultAppSettings) import qualified Simplex.Chat.AppSettings as AS import Simplex.Chat.Call -import Simplex.Chat.Controller (ChatConfig (..), DefaultAgentServers (..)) +import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options import Simplex.Chat.Protocol (supportedChatVRange) @@ -85,6 +85,8 @@ chatDirectTests = do describe "XFTP servers" $ do it "get and set XFTP servers" testGetSetXFTPServers it "test XFTP server connection" testTestXFTPServer + describe "operators and usage conditions" $ do + it "get and enable operators, accept conditions" testOperators describe "async connection handshake" $ do describe "connect when initiating client goes offline" $ do it "curr" $ testAsyncInitiatingOffline testCfg testCfg @@ -240,6 +242,7 @@ testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile te bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do alice <## "server connected localhost ()" + threadDelay 250000 bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" bob ##> ("/_connect 1 " <> inv) @@ -334,8 +337,8 @@ testRetryConnectingClientTimeout tmp = do { quotaExceededTimeout = 1, messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval} }, - defaultServers = - let def@DefaultAgentServers {netCfg} = defaultServers testCfg + presetServers = + let def@PresetServers {netCfg} = presetServers testCfg in def {netCfg = (netCfg :: NetworkConfig) {tcpTimeout = 10}} } opts' = @@ -1139,24 +1142,36 @@ testSendMultiManyBatches = testGetSetSMPServers :: HasCallStack => FilePath -> IO () testGetSetSMPServers = - testChat2 aliceProfile bobProfile $ - \alice _ -> do - alice #$> ("/_servers 1 smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001") + testChat aliceProfile $ + \alice -> do + alice ##> "/_servers 1" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" + alice <## " XFTP servers" + alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002" alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok") - alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im") + alice ##> "/smp" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://1234-w==@smp1.example.im" alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok") - alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im") + -- alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im") + alice ##> "/smp" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://1234-w==:password@smp1.example.im" alice #$> ("/smp smp://2345-w==@smp2.example.im smp://3456-w==@smp3.example.im:5224", id, "ok") alice ##> "/smp" - alice <## "smp://2345-w==@smp2.example.im" - alice <## "smp://3456-w==@smp3.example.im:5224" - alice #$> ("/smp default", id, "ok") - alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001") + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://2345-w==@smp2.example.im" + alice <## " smp://3456-w==@smp3.example.im:5224" testTestSMPServerConnection :: HasCallStack => FilePath -> IO () testTestSMPServerConnection = - testChat2 aliceProfile bobProfile $ - \alice _ -> do + testChat aliceProfile $ + \alice -> do alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001" alice <## "SMP server test passed" -- to test with password: @@ -1170,24 +1185,35 @@ testTestSMPServerConnection = testGetSetXFTPServers :: HasCallStack => FilePath -> IO () testGetSetXFTPServers = - testChat2 aliceProfile bobProfile $ - \alice _ -> withXFTPServer $ do - alice #$> ("/_servers 1 xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002") + testChat aliceProfile $ + \alice -> withXFTPServer $ do + alice ##> "/_servers 1" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" + alice <## " XFTP servers" + alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002" alice #$> ("/xftp xftp://1234-w==@xftp1.example.im", id, "ok") - alice #$> ("/xftp", id, "xftp://1234-w==@xftp1.example.im") + alice ##> "/xftp" + alice <## "Your servers" + alice <## " XFTP servers" + alice <## " xftp://1234-w==@xftp1.example.im" alice #$> ("/xftp xftp://1234-w==:password@xftp1.example.im", id, "ok") - alice #$> ("/xftp", id, "xftp://1234-w==:password@xftp1.example.im") + alice ##> "/xftp" + alice <## "Your servers" + alice <## " XFTP servers" + alice <## " xftp://1234-w==:password@xftp1.example.im" alice #$> ("/xftp xftp://2345-w==@xftp2.example.im xftp://3456-w==@xftp3.example.im:5224", id, "ok") alice ##> "/xftp" - alice <## "xftp://2345-w==@xftp2.example.im" - alice <## "xftp://3456-w==@xftp3.example.im:5224" - alice #$> ("/xftp default", id, "ok") - alice #$> ("/xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002") + alice <## "Your servers" + alice <## " XFTP servers" + alice <## " xftp://2345-w==@xftp2.example.im" + alice <## " xftp://3456-w==@xftp3.example.im:5224" testTestXFTPServer :: HasCallStack => FilePath -> IO () testTestXFTPServer = - testChat2 aliceProfile bobProfile $ - \alice _ -> withXFTPServer $ do + testChat aliceProfile $ + \alice -> withXFTPServer $ do alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7002" alice <## "XFTP server test passed" -- to test with password: @@ -1199,6 +1225,36 @@ testTestXFTPServer = alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK}" alice <## "Possibly, certificate fingerprint in XFTP server address is incorrect" +testOperators :: HasCallStack => FilePath -> IO () +testOperators = + testChatCfgOpts testCfg opts' aliceProfile $ + \alice -> do + -- initial load + alice ##> "/_conditions" + alice <##. "Current conditions: 2." + alice ##> "/_operators" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" + alice <##. "The new conditions will be accepted for SimpleX Chat Ltd at " + -- set conditions notified + alice ##> "/_conditions_notified 2" + alice <## "ok" + alice ##> "/_operators" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" + alice ##> "/_conditions" + alice <##. "Current conditions: 2 (notified)." + -- accept conditions + alice ##> "/_accept_conditions 2 1,2" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: accepted (" + alice <##. "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: accepted (" + -- update operators + alice ##> "/operators 2:on:smp=proxy" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: accepted (" + alice <##. "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: SMP enabled proxy, XFTP enabled, conditions: accepted (" + where + opts' = testOpts {coreOptions = testCoreOpts {smpServers = [], xftpServers = []}} + testAsyncInitiatingOffline :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO () testAsyncInitiatingOffline aliceCfg bobCfg tmp = do inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do @@ -1800,11 +1856,15 @@ testCreateUserSameServers = where checkCustomServers alice = do alice ##> "/smp" - alice <## "smp://2345-w==@smp2.example.im" - alice <## "smp://3456-w==@smp3.example.im:5224" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://2345-w==@smp2.example.im" + alice <## " smp://3456-w==@smp3.example.im:5224" alice ##> "/xftp" - alice <## "xftp://2345-w==@xftp2.example.im" - alice <## "xftp://3456-w==@xftp3.example.im:5224" + alice <## "Your servers" + alice <## " XFTP servers" + alice <## " xftp://2345-w==@xftp2.example.im" + alice <## " xftp://3456-w==@xftp3.example.im:5224" testDeleteUser :: HasCallStack => FilePath -> IO () testDeleteUser = diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a7de42128c..bdd3b53829 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1,8 +1,10 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module ChatTests.Groups where @@ -1986,7 +1988,6 @@ testGroupAsync tmp = do (bob <## "#team: you joined the group") alice #> "#team hello bob" bob <# "#team alice> hello bob" - print (1 :: Integer) withTestChat tmp "alice" $ \alice -> do withNewTestChat tmp "cath" cathProfile $ \cath -> do alice <## "1 contacts connected (use /cs for the list)" @@ -2006,7 +2007,6 @@ testGroupAsync tmp = do ] alice #> "#team hello cath" cath <# "#team alice> hello cath" - print (2 :: Integer) withTestChat tmp "bob" $ \bob -> do withTestChat tmp "cath" $ \cath -> do concurrentlyN_ @@ -2022,7 +2022,6 @@ testGroupAsync tmp = do cath <## "#team: member bob (Bob) is connected" ] threadDelay 500000 - print (3 :: Integer) withTestChat tmp "bob" $ \bob -> do withNewTestChat tmp "dan" danProfile $ \dan -> do bob <## "2 contacts connected (use /cs for the list)" @@ -2042,7 +2041,6 @@ testGroupAsync tmp = do ] threadDelay 1000000 threadDelay 1000000 - print (4 :: Integer) withTestChat tmp "alice" $ \alice -> do withTestChat tmp "cath" $ \cath -> do withTestChat tmp "dan" $ \dan -> do @@ -2064,7 +2062,6 @@ testGroupAsync tmp = do dan <## "#team: member cath (Catherine) is connected" ] threadDelay 1000000 - print (5 :: Integer) withTestChat tmp "alice" $ \alice -> do withTestChat tmp "bob" $ \bob -> do withTestChat tmp "cath" $ \cath -> do diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 06ed9aa5bc..3ff8808541 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -2,6 +2,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} {-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module ChatTests.Profiles where @@ -272,6 +273,7 @@ testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do alice <## "server connected localhost ()" + threadDelay 250000 bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: ok to connect" bob ##> ("/_connect 1 " <> cLink) @@ -1733,7 +1735,15 @@ testChangePCCUserDiffSrv tmp = do -- Create new user with different servers alice ##> "/create user alisa" showActiveUser alice "alisa" - alice #$> ("/smp smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003", id, "ok") + alice ##> "/smp" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" + alice #$> ("/smp smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003", id, "ok") + alice ##> "/smp" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003" alice ##> "/user alice" showActiveUser alice "alice (Alice)" -- Change connection to newly created user and use the newly created connection diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs new file mode 100644 index 0000000000..0a00d7b83c --- /dev/null +++ b/tests/OperatorTests.hs @@ -0,0 +1,132 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -Wno-orphans #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module OperatorTests (operatorTests) where + +import Data.Bifunctor (second) +import qualified Data.List.NonEmpty as L +import Simplex.Chat +import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) +import Simplex.Chat.Operators +import Simplex.Chat.Types +import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) +import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) +import Simplex.Messaging.Protocol +import Test.Hspec + +operatorTests :: Spec +operatorTests = describe "managing server operators" $ do + validateServersTest + updatedServersTest + +validateServersTest :: Spec +validateServersTest = describe "validate user servers" $ do + it "should pass valid user servers" $ validateUserServers [valid] [] `shouldBe` [] + it "should fail without servers" $ do + validateUserServers [invalidNoServers] [] `shouldBe` [USENoServers aSMP Nothing] + validateUserServers [invalidDisabled] [] `shouldBe` [USENoServers aSMP Nothing] + validateUserServers [invalidDisabledOp] [] `shouldBe` [USENoServers aSMP Nothing, USENoServers aXFTP Nothing] + it "should fail without servers with storage role" $ do + validateUserServers [invalidNoStorage] [] `shouldBe` [USEStorageMissing aSMP Nothing] + it "should fail with duplicate host" $ do + validateUserServers [invalidDuplicate] [] `shouldBe` + [ USEDuplicateServer aSMP "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion" "smp8.simplex.im", + USEDuplicateServer aSMP "smp://abcd@smp8.simplex.im" "smp8.simplex.im" + ] + where + aSMP = AProtocolType SPSMP + aXFTP = AProtocolType SPXFTP + +updatedServersTest :: Spec +updatedServersTest = describe "validate user servers" $ do + it "adding preset operators on first start" $ do + let ops' :: [(Maybe PresetOperator, Maybe AServerOperator)] = + updatedServerOperators operators [] + length ops' `shouldBe` 2 + all addedPreset ops' `shouldBe` True + let ops'' :: [(Maybe PresetOperator, Maybe ServerOperator)] = + saveOps ops' -- mock getUpdateServerOperators + uss <- groupByOperator' (ops'', [], []) -- no stored servers + length uss `shouldBe` 3 + [op1, op2, op3] <- pure $ map updatedUserServers uss + [p1, p2] <- pure operators -- presets + sameServers p1 op1 + sameServers p2 op2 + null (servers' SPSMP op3) `shouldBe` True + null (servers' SPXFTP op3) `shouldBe` True + it "adding preset operators and assiging servers to operator for existing users" $ do + let ops' = updatedServerOperators operators [] + ops'' = saveOps ops' + uss <- + groupByOperator' + ( ops'', + saveSrvs $ take 3 simplexChatSMPServers <> [newUserServer "smp://abcd@smp.example.im"], + saveSrvs $ map (presetServer True) $ L.take 3 defaultXFTPServers + ) + [op1, op2, op3] <- pure $ map updatedUserServers uss + [p1, p2] <- pure operators -- presets + sameServers p1 op1 + sameServers p2 op2 + map srvHost' (servers' SPSMP op3) `shouldBe` [["smp.example.im"]] + null (servers' SPXFTP op3) `shouldBe` True + where + addedPreset = \case + (Just PresetOperator {operator = Just op}, Just (ASO SDBNew op')) -> operatorTag op == operatorTag op' + _ -> False + saveOps = zipWith (\i -> second ((\(ASO _ op) -> op {operatorId = DBEntityId i}) <$>)) [1..] + saveSrvs = zipWith (\i srv -> srv {serverId = DBEntityId i}) [1..] + sameServers preset op = do + map srvHost (pServers SPSMP preset) `shouldBe` map srvHost' (servers' SPSMP op) + map srvHost (pServers SPXFTP preset) `shouldBe` map srvHost' (servers' SPXFTP op) + srvHost' (AUS _ s) = srvHost s + PresetServers {operators} = presetServers defaultChatConfig + +deriving instance Eq User + +deriving instance Eq UserServersError + +valid :: UpdatedUserOperatorServers +valid = + UpdatedUserOperatorServers + { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1}, + smpServers = map (AUS SDBNew) simplexChatSMPServers, + xftpServers = map (AUS SDBNew . presetServer True) $ L.toList defaultXFTPServers + } + +invalidNoServers :: UpdatedUserOperatorServers +invalidNoServers = (valid :: UpdatedUserOperatorServers) {smpServers = []} + +invalidDisabled :: UpdatedUserOperatorServers +invalidDisabled = + (valid :: UpdatedUserOperatorServers) + { smpServers = map (AUS SDBNew . (\srv -> (srv :: NewUserServer 'PSMP) {enabled = False})) simplexChatSMPServers + } + +invalidDisabledOp :: UpdatedUserOperatorServers +invalidDisabledOp = + (valid :: UpdatedUserOperatorServers) + { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1, enabled = False} + } + +invalidNoStorage :: UpdatedUserOperatorServers +invalidNoStorage = + (valid :: UpdatedUserOperatorServers) + { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1, smpRoles = allRoles {storage = False}} + } + +invalidDuplicate :: UpdatedUserOperatorServers +invalidDuplicate = + (valid :: UpdatedUserOperatorServers) + { smpServers = map (AUS SDBNew) $ simplexChatSMPServers <> [presetServer True "smp://abcd@smp8.simplex.im"] + } diff --git a/tests/RandomServers.hs b/tests/RandomServers.hs index 0c6baa71bb..9b83be26c4 100644 --- a/tests/RandomServers.hs +++ b/tests/RandomServers.hs @@ -1,51 +1,66 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TypeSynonymInstances #-} {-# OPTIONS_GHC -Wno-orphans #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module RandomServers where import Control.Monad (replicateM) -import qualified Data.List.NonEmpty as L -import Simplex.Chat (cfgServers, cfgServersToUse, defaultChatConfig, randomServers) -import Simplex.Chat.Controller (ChatConfig (..)) -import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..)) +import Data.Foldable (foldMap') +import Data.List (sortOn) +import Data.List.NonEmpty (NonEmpty) +import Data.Monoid (Sum (..)) +import Simplex.Chat (defaultChatConfig, chooseRandomServers) +import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) +import Simplex.Chat.Operators +import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..)) import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), SProtocolType (..), UserProtocol) import Test.Hspec randomServersTests :: Spec randomServersTests = describe "choosig random servers" $ do - it "should choose 4 random SMP servers and keep the rest disabled" testRandomSMPServers - it "should keep all 6 XFTP servers" testRandomXFTPServers + it "should choose 4 + 3 random SMP servers and keep the rest disabled" testRandomSMPServers + it "should choose 3 + 3 random XFTP servers and keep the rest disabled" testRandomXFTPServers -deriving instance Eq (ServerCfg p) +deriving instance Eq ServerRoles + +deriving instance Eq (UserServer' s p) testRandomSMPServers :: IO () testRandomSMPServers = do [srvs1, srvs2, srvs3] <- replicateM 3 $ - checkEnabled SPSMP 4 False =<< randomServers SPSMP defaultChatConfig + checkEnabled SPSMP 7 False =<< chooseRandomServers (presetServers defaultChatConfig) (srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` False -- && to avoid rare failures testRandomXFTPServers :: IO () testRandomXFTPServers = do [srvs1, srvs2, srvs3] <- replicateM 3 $ - checkEnabled SPXFTP 6 True =<< randomServers SPXFTP defaultChatConfig - (srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` True + checkEnabled SPXFTP 6 False =<< chooseRandomServers (presetServers defaultChatConfig) + (srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` False -- && to avoid rare failures -checkEnabled :: UserProtocol p => SProtocolType p -> Int -> Bool -> (L.NonEmpty (ServerCfg p), [ServerCfg p]) -> IO [ServerCfg p] -checkEnabled p n allUsed (srvs, _) = do - let def = defaultServers defaultChatConfig - cfgSrvs = L.sortWith server' $ cfgServers p def - toUse = cfgServersToUse p def - srvs == cfgSrvs `shouldBe` allUsed - L.map enable srvs `shouldBe` L.map enable cfgSrvs - let enbldSrvs = L.filter (\ServerCfg {enabled} -> enabled) srvs +checkEnabled :: UserProtocol p => SProtocolType p -> Int -> Bool -> NonEmpty (PresetOperator) -> IO [NewUserServer p] +checkEnabled p n allUsed presetOps' = do + let PresetServers {operators = presetOps} = presetServers defaultChatConfig + presetSrvs = sortOn server' $ concatMap (pServers p) presetOps + srvs' = sortOn server' $ concatMap (pServers p) presetOps' + Sum toUse = foldMap' (Sum . operatorServersToUse p) presetOps + Sum toUse' = foldMap' (Sum . operatorServersToUse p) presetOps' + length presetOps `shouldBe` length presetOps' + toUse `shouldBe` toUse' + srvs' == presetSrvs `shouldBe` allUsed + map enable srvs' `shouldBe` map enable presetSrvs + let enbldSrvs = filter (\UserServer {enabled} -> enabled) srvs' toUse `shouldBe` n length enbldSrvs `shouldBe` n pure enbldSrvs where - server' ServerCfg {server = ProtoServerWithAuth srv _} = srv - enable :: forall p. ServerCfg p -> ServerCfg p - enable srv = (srv :: ServerCfg p) {enabled = False} + server' UserServer {server = ProtoServerWithAuth srv _} = srv + enable :: forall p. NewUserServer p -> NewUserServer p + enable srv = (srv :: NewUserServer p) {enabled = False} diff --git a/tests/Test.hs b/tests/Test.hs index 3d59b840dd..079c583a6e 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -10,6 +10,7 @@ import MarkdownTests import MessageBatching import MobileTests import ProtocolTests +import OperatorTests import RandomServers import RemoteTests import SchemaDump @@ -31,6 +32,7 @@ main = do around tmpBracket $ describe "WebRTC encryption" webRTCTests describe "Valid names" validNameTests describe "Message batching" batchingTests + describe "Operators" operatorTests describe "Random servers" randomServersTests around testBracket $ do describe "Mobile API Tests" mobileTests