ios: server operators ui (#5114)

* wip

* refactor, fix bindings

* wip

* wip

* fixes

* wip

* information map, logos

* global conditions hack

* restructure

* restructure

* texts

* text

* restructure

* wip

* restructure

* rename

* wip

* conditions for all

* comment

* onboarding wip

* onboarding wip

* fix paddings

* fix paddings

* wip

* fix padding

* onboarding wip

* nav link instead of sheet

* pretty button

* large titles

* notifications mode button style

* reenable demo operator

* Revert "reenable demo operator"

This reverts commit 42111eb333.

* padding

* reenable demo operator

* refactor (removes additional model api)

* style

* bold

* bold

* light/dark

* fix button

* comment

* wip

* remove preset

* new types

* api types

* apis

* smp and xftp servers in single view

* test operator servers, refactor

* save in main view

* better progress

* better in progress

* remove shadow

* update

* apis

* conditions view wip

* load text

* remove custom servers button from onboarding, open already conditions in nav link

* allow to continue with simplex on onboarding

* footer

* existing users notice

* fix to not show nothing on no action

* disable notice

* review later

* disable notice

* wip

* wip

* wip

* wip

* optional tag

* fix

* fix tags

* fix

* wip

* remove coding keys

* fix onboarding

* rename

* rework model wip

* wip

* wip

* wip

* fix

* wip

* wip

* delete

* simplify

* wip

* fix delete

* ios: server operators ui wip

* refactor

* edited

* save servers on dismiss/back

* ios: add address card and remove address from onboarding (#5181)

* ios: add address card and remove address from onboarding

* allow for address creation in info when open via card

* conditions interactions wip

* conditions interactions wip

* fix

* wip

* wip

* wip

* wip

* rename

* wip

* fix

* remove operator binding

* fix set enabled

* rename

* cleanup

* text

* fix info view dark mode

* update lib

* ios: operators & servers validation

* fix

* ios: align onboarding style

* ios: align onboarding style

* ios: operators info (#5207)

* ios: operators info

* update

* update texts

* texts

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

---------

Co-authored-by: Diogo <diogofncunha@gmail.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
spaced4ndy 2024-11-19 15:37:00 +04:00 committed by GitHub
parent fcae5e9925
commit 70a29512b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 3014 additions and 740 deletions

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -9,6 +9,16 @@ import SwiftUI
import Intents
import SimpleXChat
private enum NoticesSheet: Identifiable {
case notices(showWhatsNew: Bool, showOperatorsNotice: Bool)
var id: String {
switch self {
case .notices: return "notices"
}
}
}
struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared
@ -30,7 +40,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 +272,13 @@ 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 showOperatorsNotice = chatModel.conditions.conditionsAction?.showNotice ?? false
noticesShown = showWhatsNew || showOperatorsNotice
if noticesShown {
noticesSheetItem = .notices(showWhatsNew: showWhatsNew, showOperatorsNotice: showOperatorsNotice)
}
}
}
}
@ -270,8 +286,11 @@ struct ContentView: View {
connectViaUrl()
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.sheet(isPresented: $showWhatsNew) {
WhatsNewView()
.sheet(item: $noticesSheetItem) { item in
switch item {
case let .notices(showWhatsNew, showOperatorsNotice):
WhatsNewView(showWhatsNew: showWhatsNew, showOperatorsNotice: showOperatorsNotice)
}
}
if chatModel.setDeliveryReceipts {
SetDeliveryReceiptsView()

View file

@ -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<Int64, () -> Void> = [:]

View file

@ -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)
@ -1624,7 +1672,7 @@ func startChat(refreshInvitations: Bool = true) throws {
withAnimation {
let savedOnboardingStage = onboardingStageDefault.get()
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
? .step3_CreateSimpleXAddress
? .step3_ChooseServerOperators
: savedOnboardingStage
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
m.setDeliveryReceipts = true

View file

@ -36,6 +36,10 @@ struct UserPickerSheetView: View {
@EnvironmentObject var chatModel: ChatModel
@State private var loaded = false
@State private var currUserServers: [UserOperatorServers] = []
@State private var userServers: [UserOperatorServers] = []
@State private var serverErrors: [UserServersError] = []
var body: some View {
NavigationView {
ZStack {
@ -56,7 +60,11 @@ struct UserPickerSheetView: View {
case .useFromDesktop:
ConnectDesktopView()
case .settings:
SettingsView()
SettingsView(
currUserServers: $currUserServers,
userServers: $userServers,
serverErrors: $serverErrors
)
}
}
Color.clear // Required for list background to be rendered during loading
@ -76,6 +84,16 @@ struct UserPickerSheetView: View {
{ loaded = true }
)
}
.onDisappear {
if serversCanBeSaved(currUserServers, userServers, serverErrors) {
showAlert(
title: NSLocalizedString("Save servers?", comment: "alert title"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
buttonAction: { saveServers($currUserServers, $userServers) },
cancelButton: true
)
}
}
}
}
@ -94,6 +112,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 {
@ -282,6 +301,12 @@ struct ChatListView: View {
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
if !addressCreationCardShown {
AddressCreationCard()
.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)

View file

@ -20,6 +20,10 @@ struct ServersSummaryView: View {
@State private var timer: Timer? = nil
@State private var alert: SomeAlert?
@State private var currUserServers: [UserOperatorServers] = []
@State private var userServers: [UserOperatorServers] = []
@State private var serverErrors: [UserServersError] = []
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
enum PresentedUserCategory {
@ -53,6 +57,15 @@ struct ServersSummaryView: View {
}
.onDisappear {
stopTimer()
if serversCanBeSaved(currUserServers, userServers, serverErrors) {
showAlert(
title: NSLocalizedString("Save servers?", comment: "alert title"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
buttonAction: { saveServers($currUserServers, $userServers) },
cancelButton: true
)
}
}
.alert(item: $alert) { $0.alert }
}
@ -275,7 +288,10 @@ struct ServersSummaryView: View {
NavigationLink(tag: srvSumm.id, selection: $selectedSMPServer) {
SMPServerSummaryView(
summary: srvSumm,
statsStartedAt: statsStartedAt
statsStartedAt: statsStartedAt,
currUserServers: $currUserServers,
userServers: $userServers,
serverErrors: $serverErrors
)
.navigationBarTitle("SMP server")
.navigationBarTitleDisplayMode(.large)
@ -344,7 +360,10 @@ struct ServersSummaryView: View {
NavigationLink(tag: srvSumm.id, selection: $selectedXFTPServer) {
XFTPServerSummaryView(
summary: srvSumm,
statsStartedAt: statsStartedAt
statsStartedAt: statsStartedAt,
currUserServers: $currUserServers,
userServers: $userServers,
serverErrors: $serverErrors
)
.navigationBarTitle("XFTP server")
.navigationBarTitleDisplayMode(.large)
@ -486,6 +505,10 @@ struct SMPServerSummaryView: View {
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
@Binding var currUserServers: [UserOperatorServers]
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
var body: some View {
List {
Section("Server address") {
@ -493,9 +516,13 @@ struct SMPServerSummaryView: View {
.textSelection(.enabled)
if summary.known == true {
NavigationLink {
ProtocolServersView(serverProtocol: .smp)
.navigationTitle("Your SMP servers")
.modifier(ThemedBackground(grouped: true))
NetworkAndServers(
currUserServers: $currUserServers,
userServers: $userServers,
serverErrors: $serverErrors
)
.navigationTitle("Network & servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}
@ -674,6 +701,10 @@ struct XFTPServerSummaryView: View {
var summary: XFTPServerSummary
var statsStartedAt: Date
@Binding var currUserServers: [UserOperatorServers]
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
var body: some View {
List {
Section("Server address") {
@ -681,9 +712,13 @@ struct XFTPServerSummaryView: View {
.textSelection(.enabled)
if summary.known == true {
NavigationLink {
ProtocolServersView(serverProtocol: .xftp)
.navigationTitle("Your XFTP servers")
.modifier(ThemedBackground(grouped: true))
NetworkAndServers(
currUserServers: $currUserServers,
userServers: $userServers,
serverErrors: $serverErrors
)
.navigationTitle("Network & servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}

View file

@ -0,0 +1,116 @@
//
// 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)
.padding(.vertical, 12)
.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()
}

View file

@ -0,0 +1,344 @@
//
// ChooseServerOperators.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 31.10.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
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)
}
}
struct ChooseServerOperators: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Environment(\.colorScheme) var colorScheme: ColorScheme
@EnvironmentObject var theme: AppTheme
var onboarding: Bool
@State private var showInfoSheet = false
@State private var serverOperators: [ServerOperator] = []
@State private var selectedOperatorIds = Set<Int64>()
@State private var reviewConditionsNavLinkActive = false
@State private var justOpened = true
var selectedOperators: [ServerOperator] { serverOperators.filter { selectedOperatorIds.contains($0.operatorId) } }
var body: some View {
NavigationView {
GeometryReader { g in
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Choose operators")
.font(.largeTitle)
.bold()
infoText()
Spacer()
ForEach(serverOperators) { srvOperator in
operatorCheckView(srvOperator)
}
Spacer()
let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed }
VStack(spacing: 8) {
if !reviewForOperators.isEmpty {
reviewConditionsButton()
} else {
continueButton()
}
if onboarding {
Text("You can disable operators and configure your servers in Network & servers settings.")
.multilineTextAlignment(.center)
.font(.footnote)
.padding(.horizontal, 32)
}
}
.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(isPresented: $showInfoSheet) {
ChooseServerOperatorsInfoView()
}
}
.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 {
showInfoSheet = true
}
Text("Select operators, whose servers you will be using.")
}
}
@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(Color(.systemBackground))
.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 continueButton() -> some View {
Button {
continueToNextStep()
} label: {
Text("Continue")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
.disabled(selectedOperatorIds.isEmpty)
}
private func reviewLaterButton() -> some View {
Button {
continueToNextStep()
} label: {
Text("Review later")
}
.buttonStyle(.borderless)
}
private func continueToNextStep() {
if onboarding {
withAnimation {
onboardingStageDefault.set(.step4_SetNotificationsMode)
ChatModel.shared.onboardingStage = .step4_SetNotificationsMode
}
} else {
dismiss()
}
}
private func reviewConditionsDestinationView() -> some View {
reviewConditionsView()
.navigationTitle("Conditions of use")
.navigationBarTitleDisplayMode(.large)
.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 {
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..<ops.count {
var op = ops[i]
op.enabled = selectedOperatorIds.contains(op.operatorId)
ops[i] = op
}
let haveSMPStorage = ops.contains(where: { $0.enabled && $0.smpRoles.storage })
let haveSMPProxy = ops.contains(where: { $0.enabled && $0.smpRoles.proxy })
let haveXFTPStorage = ops.contains(where: { $0.enabled && $0.xftpRoles.storage })
let haveXFTPProxy = ops.contains(where: { $0.enabled && $0.xftpRoles.proxy })
if haveSMPStorage && haveSMPProxy && haveXFTPStorage && haveXFTPProxy {
return ops
} else if let firstEnabledIndex = ops.firstIndex(where: { $0.enabled }) {
var op = ops[firstEnabledIndex]
if !haveSMPStorage { op.smpRoles.storage = true }
if !haveSMPProxy { op.smpRoles.proxy = true }
if !haveXFTPStorage { op.xftpRoles.storage = true }
if !haveXFTPProxy { op.xftpRoles.proxy = true }
ops[firstEnabledIndex] = op
return ops
} else { // Shouldn't happen - view doesn't let to proceed if no operators are enabled
return nil
}
} else {
return nil
}
}
}
struct ChooseServerOperatorsInfoView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Why choose multiple operators")
.font(.largeTitle)
.padding(.vertical)
ScrollView {
VStack(alignment: .leading) {
Group {
Text("Selecting multiple operators improves protection of your communication graph.")
Text("TODO Better explanation")
}
.padding(.bottom)
}
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.modifier(ThemedBackground())
}
}
#Preview {
ChooseServerOperators(onboarding: true)
}

View file

@ -88,38 +88,42 @@ struct CreateFirstProfile: View {
@FocusState private var focusDisplayName
var body: some View {
VStack(alignment: .leading) {
Group {
Text("Create your profile")
.font(.largeTitle)
.bold()
Text("Your profile, contacts and delivered messages are stored on your device.")
.foregroundColor(theme.colors.secondary)
Text("The profile is only shared with your contacts.")
.foregroundColor(theme.colors.secondary)
.padding(.bottom)
}
.padding(.bottom)
VStack(alignment: .leading, spacing: 20) {
Text("Your profile, contacts and delivered messages are stored on your device.")
.foregroundColor(theme.colors.secondary)
Text("The profile is only shared with your contacts.")
.foregroundColor(theme.colors.secondary)
ZStack(alignment: .topLeading) {
HStack {
let name = displayName.trimmingCharacters(in: .whitespaces)
let validName = mkValidName(name)
if name != validName {
Button {
showAlert(.invalidNameError(validName: validName))
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
ZStack {
if name != validName {
Button {
showAlert(.invalidNameError(validName: validName))
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
}
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
}
TextField("Enter your name…", text: $displayName)
.focused($focusDisplayName)
.padding(.leading, 32)
.padding(.horizontal)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
}
.padding(.bottom)
.padding(.top)
Spacer()
onboardingButtons()
createProfileButton()
.padding(.bottom)
}
.onAppear() {
focusDisplayName = true
@ -129,32 +133,14 @@ struct CreateFirstProfile: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
func onboardingButtons() -> some View {
HStack {
Button {
hideKeyboard()
withAnimation {
m.onboardingStage = .step1_SimpleXInfo
}
} label: {
HStack {
Image(systemName: "lessthan")
Text("About SimpleX")
}
}
Spacer()
Button {
createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
} label: {
HStack {
Text("Create")
Image(systemName: "greaterthan")
}
}
.disabled(!canCreateProfile(displayName))
func createProfileButton() -> some View {
Button {
createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
} label: {
Text("Create profile")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName)))
.disabled(!canCreateProfile(displayName))
}
private func showAlert(_ alert: UserProfileAlert) {
@ -176,8 +162,8 @@ private func createProfile(_ displayName: String, showAlert: (UserProfileAlert)
if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) {
try startChat()
withAnimation {
onboardingStageDefault.set(.step3_CreateSimpleXAddress)
m.onboardingStage = .step3_CreateSimpleXAddress
onboardingStageDefault.set(.step3_ChooseServerOperators)
m.onboardingStage = .step3_ChooseServerOperators
}
} else {
onboardingStageDefault.set(.onboardingComplete)

View file

@ -9,8 +9,10 @@
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) {
@ -37,8 +39,8 @@ struct HowItWorks: View {
Spacer()
if onboarding {
OnboardingActionButton()
.padding(.bottom, 8)
createFirstProfileButton()
.padding(.bottom)
}
}
.lineLimit(10)
@ -46,10 +48,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)
)
}
}

View file

@ -16,6 +16,7 @@ struct OnboardingView: View {
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
case .step2_CreateProfile: CreateFirstProfile()
case .step3_CreateSimpleXAddress: CreateSimpleXAddress()
case .step3_ChooseServerOperators: ChooseServerOperators(onboarding: true)
case .step4_SetNotificationsMode: SetNotificationsMode()
case .onboardingComplete: EmptyView()
}
@ -24,8 +25,9 @@ struct OnboardingView: View {
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

View file

@ -15,41 +15,44 @@ 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: 16) {
Text("Push notifications")
.font(.largeTitle)
.bold()
.frame(maxWidth: .infinity)
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) {

View file

@ -13,81 +13,85 @@ 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) {
Image(colorScheme == .light ? "logo" : "logo-light")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: g.size.width * 0.67)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, minHeight: 48, alignment: .top)
NavigationView {
GeometryReader { g in
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Image(colorScheme == .light ? "logo" : "logo-light")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: g.size.width * 0.67)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, minHeight: 48, alignment: .top)
VStack(alignment: .leading) {
Text("The next generation of private messaging")
.font(.title2)
.padding(.bottom, 30)
.padding(.horizontal, 40)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
infoRow("privacy", "Privacy redefined",
"The 1st platform without any user identifiers private by design.", width: 48)
infoRow("shield", "Immune to spam and abuse",
"People can connect to you only via the links you share.", width: 46)
infoRow(colorScheme == .light ? "decentralized" : "decentralized-light", "Decentralized",
"Open-source protocol and code anybody can run the servers.", width: 44)
}
VStack(alignment: .leading) {
Text("The next generation of private messaging")
.font(.title2)
.padding(.bottom, 30)
.padding(.horizontal, 40)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
infoRow("privacy", "Privacy redefined",
"The 1st platform without any user identifiers private by design.", width: 48)
infoRow("shield", "Immune to spam and abuse",
"People can connect to you only via the links you share.", width: 46)
infoRow(colorScheme == .light ? "decentralized" : "decentralized-light", "Decentralized",
"Open-source protocol and code anybody can run the servers.", width: 44)
}
Spacer()
if onboarding {
OnboardingActionButton()
Spacer()
if onboarding {
onboardingActionButton()
Button {
m.migrationState = .pasteOrScanLink
} label: {
Label("Migrate from another device", systemImage: "tray.and.arrow.down")
.font(.subheadline)
}
.frame(maxWidth: .infinity)
}
Button {
m.migrationState = .pasteOrScanLink
showHowItWorks = true
} label: {
Label("Migrate from another device", systemImage: "tray.and.arrow.down")
Label("How it works", systemImage: "info.circle")
.font(.subheadline)
}
.padding(.bottom, 8)
.frame(maxWidth: .infinity)
.padding(.bottom)
}
Button {
showHowItWorks = true
} label: {
Label("How it works", systemImage: "info.circle")
.font(.subheadline)
}
.padding(.bottom, 8)
.frame(maxWidth: .infinity)
.frame(minHeight: g.size.height)
}
.frame(minHeight: g.size.height)
}
.sheet(isPresented: Binding(
get: { m.migrationState != nil },
set: { _ in
m.migrationState = nil
MigrationToDeviceState.save(nil) }
)) {
NavigationView {
VStack(alignment: .leading) {
MigrateToDevice(migrationState: $m.migrationState)
.sheet(isPresented: Binding(
get: { m.migrationState != nil },
set: { _ in
m.migrationState = nil
MigrationToDeviceState.save(nil) }
)) {
NavigationView {
VStack(alignment: .leading) {
MigrateToDevice(migrationState: $m.migrationState)
}
.navigationTitle("Migrate here")
.modifier(ThemedBackground(grouped: true))
}
.navigationTitle("Migrate here")
.modifier(ThemedBackground(grouped: true))
}
.sheet(isPresented: $showHowItWorks) {
HowItWorks(
onboarding: onboarding,
createProfileNavLinkActive: $createProfileNavLinkActive
)
}
}
.sheet(isPresented: $showHowItWorks) {
HowItWorks(onboarding: onboarding)
}
.frame(maxHeight: .infinity)
.padding()
}
.frame(maxHeight: .infinity)
.padding()
}
private func infoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
@ -108,49 +112,51 @@ struct SimpleXInfo: View {
.padding(.bottom, 20)
.padding(.trailing, 6)
}
}
struct OnboardingActionButton: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
var body: some View {
@ViewBuilder private func onboardingActionButton() -> some View {
if m.currentUser == nil {
actionButton("Create your profile", onboarding: .step2_CreateProfile)
createFirstProfileButton()
} else {
actionButton("Make a private connection", onboarding: .onboardingComplete)
userExistsFallbackButton()
}
}
private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View {
Button {
withAnimation {
onboardingStageDefault.set(onboarding)
m.onboardingStage = onboarding
private func createFirstProfileButton() -> some View {
ZStack {
Button {
createProfileNavLinkActive = true
} label: {
Text("Create your profile")
}
} label: {
HStack {
Text(label).font(.title2)
Image(systemName: "greaterthan")
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
NavigationLink(isActive: $createProfileNavLinkActive) {
createProfileDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
.frame(maxWidth: .infinity)
.padding(.bottom)
}
private func actionButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
private func createProfileDestinationView() -> some View {
CreateFirstProfile()
.navigationTitle("Create your profile")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
}
private func userExistsFallbackButton() -> some View {
Button {
withAnimation {
action()
onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete
}
} label: {
HStack {
Text(label).font(.title2)
Image(systemName: "greaterthan")
}
Text("Make a private connection")
}
.frame(maxWidth: .infinity)
.padding(.bottom)
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
}

View file

@ -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,56 @@ func shouldShowWhatsNew() -> Bool {
return v != lastVersion
}
fileprivate func newOperatorsView() -> 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") {
}
Text("for better metadata privacy.")
}
}
}
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
@State var showWhatsNew: Bool
var showOperatorsNotice: Bool
var body: some View {
viewBody()
.task {
if showOperatorsNotice {
do {
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
try await setConditionsNotified(conditionsId: conditionsId)
} catch let error {
logger.error("WhatsNewView setConditionsNotified error: \(responseError(error))")
}
}
}
}
@ViewBuilder private func viewBody() -> some View {
if showWhatsNew {
whatsNewView()
} else if showOperatorsNotice {
ChooseServerOperators(onboarding: false)
}
}
private func whatsNewView() -> some View {
VStack {
TabView(selection: $currentVersion) {
ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in
@ -532,9 +609,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) {
@ -546,11 +625,21 @@ struct WhatsNewView: View {
}
if !viaSettings {
Spacer()
Button("Ok") {
dismiss()
if showOperatorsNotice {
Button("View updated conditions") {
showWhatsNew = false
}
.font(.title3)
.frame(maxWidth: .infinity, alignment: .center)
} else {
Button("Ok") {
dismiss()
}
.font(.title3)
.frame(maxWidth: .infinity, alignment: .center)
}
.font(.title3)
.frame(maxWidth: .infinity, alignment: .center)
Spacer()
}
}
@ -568,20 +657,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 +729,6 @@ struct WhatsNewView: View {
struct NewFeaturesView_Previews: PreviewProvider {
static var previews: some View {
WhatsNewView()
WhatsNewView(showWhatsNew: true, showOperatorsNotice: false)
}
}

View file

@ -19,28 +19,80 @@ private enum NetworkAlert: Identifiable {
}
}
private enum NetworkAndServersSheet: Identifiable {
case showConditions(conditionsAction: UsageConditionsAction)
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
@Binding var currUserServers: [UserOperatorServers]
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@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 = userServers.contains(where: { $0.operator?.enabled ?? false })
Section {
NavigationLink {
ProtocolServersView(serverProtocol: .smp)
.navigationTitle("Your SMP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Message servers")
ForEach(userServers.enumerated().map { $0 }, id: \.element.id) { idx, userOperatorServers in
if let serverOperator = userOperatorServers.operator {
serverOperatorView(idx, serverOperator)
} else {
EmptyView()
}
}
NavigationLink {
ProtocolServersView(serverProtocol: .xftp)
.navigationTitle("Your XFTP servers")
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 considered accepted on: \(conditionsTimestamp(deadline)).")
.foregroundColor(theme.colors.secondary)
}
default:
EmptyView()
}
}
Section {
if let idx = userServers.firstIndex(where: { $0.operator == nil }) {
NavigationLink {
YourServersView(
userServers: $userServers,
serverErrors: $serverErrors,
operatorIndex: idx
)
.navigationTitle("Your servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Media & file servers")
} label: {
HStack {
Text("Your servers")
if userServers[idx] != currUserServers[idx] {
Spacer()
unsavedChangesIndicator()
}
}
}
}
NavigationLink {
@ -55,6 +107,17 @@ struct NetworkAndServers: View {
.foregroundColor(theme.colors.secondary)
}
Section {
Button("Save servers", action: { saveServers($currUserServers, $userServers) })
.disabled(!serversCanBeSaved(currUserServers, userServers, serverErrors))
} footer: {
if let errStr = globalServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else if !serverErrors.isEmpty {
ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error"))
}
}
Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
NavigationLink {
RTCServers()
@ -74,11 +137,287 @@ struct NetworkAndServers: View {
}
}
}
.task {
// this condition is needed to prevent re-setting the servers when exiting single server view
if justOpened {
do {
currUserServers = try await getUserServers()
userServers = currUserServers
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(currUserServers, userServers, serverErrors) {
showSaveDialog = true
} else {
dismiss()
}
})
.confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) {
Button("Save") {
saveServers($currUserServers, $userServers)
dismiss()
}
Button("Exit without saving") { dismiss() }
}
.sheet(item: $sheetItem) { item in
switch item {
case let .showConditions(conditionsAction):
UsageConditionsView(
conditionsAction: conditionsAction,
currUserServers: $currUserServers,
userServers: $userServers
)
.modifier(ThemedBackground(grouped: true))
}
}
}
private func serverOperatorView(_ operatorIndex: Int, _ serverOperator: ServerOperator) -> some View {
NavigationLink() {
OperatorView(
currUserServers: $currUserServers,
userServers: $userServers,
serverErrors: $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 userServers[operatorIndex] != 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(conditionsAction: conditionsAction)
} 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
var conditionsAction: UsageConditionsAction
@Binding var currUserServers: [UserOperatorServers]
@Binding var userServers: [UserOperatorServers]
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Conditions of use")
.font(.largeTitle)
.bold()
.padding(.top)
.padding(.top)
switch conditionsAction {
case let .review(operators, _, _):
Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
ConditionsTextView()
acceptConditionsButton(operators.map { $0.operatorId })
.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<String> {
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()
await MainActor.run {
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..<usvs.wrappedValue.count {
if let updatedOperator = updatedOperators.first(where: { $0.operatorId == usvs.wrappedValue[i].operator?.operatorId }) {
usvs.wrappedValue[i].operator?.conditionsAcceptance = updatedOperator.conditionsAcceptance
}
}
}
struct NetworkServersView_Previews: PreviewProvider {
static var previews: some View {
NetworkAndServers()
NetworkAndServers(
currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([])
)
}
}

View file

@ -0,0 +1,156 @@
//
// NewServerView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 13.11.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct NewServerView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@State private var serverToEdit: UserServer = .empty
@State private var showTestFailure = false
@State private var testing = false
@State private var testFailure: ProtocolTestFailure?
var body: some View {
ZStack {
customServer()
if testing {
ProgressView().scaleEffect(2)
}
}
.modifier(BackButton(disabled: Binding.constant(false)) {
addServer(serverToEdit, $userServers, $serverErrors, dismiss)
})
.alert(isPresented: $showTestFailure) {
Alert(
title: Text("Server test failed!"),
message: Text(testFailure?.localizedDescription ?? "")
)
}
}
// TODO Possibly refactor - similar functions in ProtocolServerView
private func customServer() -> 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([])
)
}

View file

@ -0,0 +1,569 @@
//
// 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: {
HStack {
Image(userServers[operatorIndex].operator_.logo(colorScheme))
.resizable()
.scaledToFit()
.grayscale(userServers[operatorIndex].operator_.enabled ? 0.0 : 1.0)
.frame(width: 24, height: 24)
Text(userServers[operatorIndex].operator_.tradeName)
}
}
}
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 {
Text("Use servers of \(userServers[operatorIndex].operator_.tradeName)")
.font(.largeTitle)
.bold()
.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 {
VStack(spacing: 20) {
ConditionsTextView()
.padding(.top)
acceptConditionsButton()
.padding(.bottom)
.padding(.bottom)
}
.padding(.horizontal)
.navigationTitle("Conditions of use")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
}
}
#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
)
}

View file

@ -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<ServerCfg>) async -> ProtocolTestFailure? {
func testServerConnection(server: Binding<UserServer>) async -> ProtocolTestFailure? {
do {
let r = try await testProtoServer(server: server.wrappedValue.server)
switch r {
@ -178,9 +199,11 @@ func testServerConnection(server: Binding<ServerCfg>) 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"
)
}
}

View file

@ -11,238 +11,166 @@ import SimpleXChat
private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")!
struct ProtocolServersView: View {
struct YourServersView: 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] = []
@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 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)
yourServersView()
.opacity(testing ? 0.4 : 1)
.overlay {
if testing {
ProgressView()
.scaleEffect(2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
.allowsHitTesting(!testing)
}
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 {
@ViewBuilder private func yourServersView() -> some View {
let duplicateHosts = findDuplicateHosts(serverErrors)
List {
if !configuredServers.isEmpty {
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
Section {
ForEach($configuredServers) { srv in
protocolServerView(srv)
}
.onMove { indexSet, offset in
configuredServers.move(fromOffsets: indexSet, toOffset: offset)
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
configuredServers.remove(atOffsets: indexSet)
deleteSMPServer($userServers, operatorIndex, indexSet)
validateServers_($userServers, $serverErrors)
}
} header: {
Text("Configured \(proto) servers")
Text("Message 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 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 !otherServers.isEmpty {
if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty {
Section {
ForEach($otherServers) { srv in
protocolServerView(srv)
}
.onMove { indexSet, offset in
otherServers.move(fromOffsets: indexSet, toOffset: offset)
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
otherServers.remove(atOffsets: indexSet)
deleteXFTPServer($userServers, operatorIndex, indexSet)
validateServers_($userServers, $serverErrors)
}
} header: {
Text("Other \(proto) servers")
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 {
Button("Add server") {
showAddServer = true
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 {
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)
TestServersButton(
smpServers: $userServers[operatorIndex].smpServers,
xftpServers: $userServers[operatorIndex].xftpServers,
testing: $testing
)
howToButton()
}
}
.toolbar { EditButton() }
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
Button("Enter server manually") {
otherServers.append(ServerCfg.empty)
selectedServer = allServers.last?.id
.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 }
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<ServerCfg>) -> some View {
let srv = server.wrappedValue
return NavigationLink(tag: srv.id, selection: $selectedServer) {
ProtocolServerView(
serverProtocol: serverProtocol,
server: server,
serverToEdit: srv
ScanProtocolServer(
userServers: $userServers,
serverErrors: $serverErrors
)
.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)
}
}
}
}
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 {
@ -255,33 +183,114 @@ struct ProtocolServersView: View {
}
}
}
}
struct ProtocolServerViewLink: View {
@EnvironmentObject var theme: AppTheme
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
var duplicateHosts: Set<String>
@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)
}
}
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)
}
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
}
}
}
private func hasAllPresets() -> Bool {
presetServers.allSatisfy { hasPreset($0) }
}
private func addAllPresets() {
for srv in presetServers {
if !hasPreset(srv) {
configuredServers.append(srv)
}
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
}
}
}
private func hasPreset(_ srv: ServerCfg) -> Bool {
allServers.contains(where: { $0.server == srv.server })
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() {
@ -292,68 +301,59 @@ struct ProtocolServersView: View {
await MainActor.run {
testing = false
if !fs.isEmpty {
alert = .testsFailed(failures: fs)
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..<configuredServers.count {
if configuredServers[i].enabled {
configuredServers[i].tested = nil
for i in 0..<smpServers.count {
if smpServers[i].enabled {
smpServers[i].tested = nil
}
}
for i in 0..<otherServers.count {
if otherServers[i].enabled {
otherServers[i].tested = nil
for i in 0..<xftpServers.count {
if xftpServers[i].enabled {
xftpServers[i].tested = nil
}
}
}
private func runServersTest() async -> [String: ProtocolTestFailure] {
var fs: [String: ProtocolTestFailure] = [:]
for i in 0..<configuredServers.count {
if configuredServers[i].enabled {
if let f = await testServerConnection(server: $configuredServers[i]) {
fs[serverHostname(configuredServers[i].server)] = f
for i in 0..<smpServers.count {
if smpServers[i].enabled {
if let f = await testServerConnection(server: $smpServers[i]) {
fs[serverHostname(smpServers[i].server)] = f
}
}
}
for i in 0..<otherServers.count {
if otherServers[i].enabled {
if let f = await testServerConnection(server: $otherServers[i]) {
fs[serverHostname(otherServers[i].server)] = f
for i in 0..<xftpServers.count {
if xftpServers[i].enabled {
if let f = await testServerConnection(server: $xftpServers[i]) {
fs[serverHostname(xftpServers[i].server)] = f
}
}
}
return fs
}
func saveServers() {
Task {
do {
try await setUserProtoServers(serverProtocol, servers: allServers)
await MainActor.run {
currServers = allServers
editMode?.wrappedValue = .inactive
}
} catch let error {
let err = responseError(error)
logger.error("saveServers setUserProtocolServers error: \(err)")
await MainActor.run {
alert = .error(
title: "Error saving \(proto) servers",
error: "Make sure \(proto) server addresses are in correct format, line separated and are not duplicated (\(responseError(error)))."
)
}
}
}
}
}
struct ProtocolServersView_Previews: PreviewProvider {
struct YourServersView_Previews: PreviewProvider {
static var previews: some View {
ProtocolServersView(serverProtocol: .smp)
YourServersView(
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([]),
operatorIndex: 1
)
}
}

View file

@ -12,8 +12,8 @@ import CodeScanner
struct ScanProtocolServer: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var servers: [ServerCfg]
@State private var showAddressError = false
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
var body: some View {
VStack(alignment: .leading) {
@ -28,23 +28,14 @@ struct ScanProtocolServer: View {
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(isPresented: $showAddressError) {
Alert(
title: Text("Invalid server address!"),
message: Text("Check server address and try again.")
)
}
}
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
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([])
)
}
}

View file

@ -50,6 +50,7 @@ let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
@ -107,6 +108,7 @@ let appDefaults: [String: Any] = [
DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness,
DEFAULT_CHAT_ITEM_TAIL: true,
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false,
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
@ -135,6 +137,7 @@ let appDefaults: [String: Any] = [
let hintDefaults = [
DEFAULT_LA_NOTICE_SHOWN,
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
DEFAULT_SHOW_MUTE_PROFILE_ALERT,
@ -263,6 +266,10 @@ struct SettingsView: View {
@EnvironmentObject var theme: AppTheme
@State private var showProgress: Bool = false
@Binding var currUserServers: [UserOperatorServers]
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
var body: some View {
ZStack {
settingsView()
@ -289,9 +296,13 @@ struct SettingsView: View {
.disabled(chatModel.chatRunning != true)
NavigationLink {
NetworkAndServers()
.navigationTitle("Network & servers")
.modifier(ThemedBackground(grouped: true))
NetworkAndServers(
currUserServers: $currUserServers,
userServers: $userServers,
serverErrors: $serverErrors
)
.navigationTitle("Network & servers")
.modifier(ThemedBackground(grouped: true))
} label: {
settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") }
}
@ -356,7 +367,7 @@ struct SettingsView: View {
}
}
NavigationLink {
WhatsNewView(viaSettings: true)
WhatsNewView(viaSettings: true, showWhatsNew: true, showOperatorsNotice: false)
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.inline)
} label: {
@ -525,7 +536,11 @@ struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.currentUser = User.sampleData
return SettingsView()
.environmentObject(chatModel)
return SettingsView(
currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([])
)
.environmentObject(chatModel)
}
}

View file

@ -9,15 +9,47 @@
import SwiftUI
struct UserAddressLearnMore: View {
@State var showCreateAddressButton = false
@State private var createAddressLinkActive = false
var body: some View {
List {
VStack(alignment: .leading, spacing: 18) {
Text("You can share your address as a link or QR code - anybody can connect to you.")
Text("You won't lose your contacts if you later delete your address.")
Text("When people request to connect, you can accept or reject it.")
Text("Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).")
VStack {
List {
VStack(alignment: .leading, spacing: 18) {
Text("You can share your address as a link or QR code - anybody can connect to you.")
Text("You won't lose your contacts if you later delete your address.")
Text("When people request to connect, you can accept or reject it.")
Text("Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).")
}
.listRowBackground(Color.clear)
}
.listRowBackground(Color.clear)
.frame(maxHeight: .infinity)
if showCreateAddressButton {
addressCreationButton()
.padding()
}
}
}
private func addressCreationButton() -> 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()
}
}
}

View file

@ -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) {

View file

@ -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 = "<group>"; };
5CB634AE29E4BB7D0066AD6B /* SetAppPasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAppPasscodeView.swift; sourceTree = "<group>"; };
5CB634B029E5EFEA0066AD6B /* PasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasscodeView.swift; sourceTree = "<group>"; };
5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; wrapsLines = 0; };
5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = "<group>"; };
5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListNavLink.swift; sourceTree = "<group>"; };
5CBD285529565CAE00EC2CF4 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
@ -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 = "<group>"; };
640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = "<group>"; };
640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = "<group>"; };
6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = "<group>"; };
6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextInvitingContactMemberView.swift; sourceTree = "<group>"; };
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = "<group>"; };
642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a"; sourceTree = "<group>"; };
642BA82F2CEB3D4B005E9412 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
642BA8302CEB3D4B005E9412 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
642BA8312CEB3D4B005E9412 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
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 = "<group>"; };
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
643B3B402CCBEB080083A2CF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmpxx.a; path = Libraries/libgmpxx.a; sourceTree = "<group>"; };
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 = "<group>"; };
643B3B422CCBEB080083A2CF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libffi.a; path = Libraries/libffi.a; sourceTree = "<group>"; };
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 = "<group>"; };
643B3B442CCBEB080083A2CF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmp.a; path = Libraries/libgmp.a; sourceTree = "<group>"; };
643B3B4D2CCFD6400083A2CF /* OperatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorView.swift; sourceTree = "<group>"; };
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = "<group>"; };
64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = "<group>"; };
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = "<group>"; };
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = "<group>"; };
@ -544,7 +550,9 @@
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = "<group>"; };
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 = "<group>";
@ -1056,8 +1066,10 @@
isa = PBXGroup;
children = (
5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */,
642BA82C2CE50495005E9412 /* NewServerView.swift */,
5C93293029239BED0090FFF9 /* ProtocolServerView.swift */,
5C93292E29239A170090FFF9 /* ProtocolServersView.swift */,
643B3B4D2CCFD6400083A2CF /* OperatorView.swift */,
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
);
@ -1383,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 */,
@ -1413,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 */,
@ -1536,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;
};

View file

@ -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,426 @@ 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<OperatorTag, ServerOperatorInfo> = [
.simplex: ServerOperatorInfo(
description: ["SimpleX Chat preset servers"],
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 invalidServer(protocol: ServerProtocol, invalidServer: String)
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
}
}