+ @State private var sheetItem: ChooseServerOperatorsSheet? = nil
+
+ var body: some View {
+ GeometryReader { g in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ Text("Server operators")
+ .font(.largeTitle)
+ .bold()
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.top, 25)
+
+ infoText()
+ .frame(maxWidth: .infinity, alignment: .center)
+
+ Spacer()
+
+ ForEach(serverOperators) { srvOperator in
+ operatorCheckView(srvOperator)
+ }
+ VStack {
+ Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8)
+ Text("You can configure servers via settings.")
+ }
+ .font(.footnote)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.horizontal, 16)
+
+ Spacer()
+
+ VStack(spacing: 8) {
+ setOperatorsButton()
+ onboardingButtonPlaceholder()
+ }
+ }
+ .frame(minHeight: g.size.height)
+ }
+ .sheet(item: $sheetItem) { item in
+ switch item {
+ case .showInfo:
+ ChooseServerOperatorsInfoView()
+ }
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ .padding(25)
+ .interactiveDismissDisabled(selectedOperatorIds.isEmpty)
+ }
+
+ private func infoText() -> some View {
+ Button {
+ sheetItem = .showInfo
+ } label: {
+ Label("How it helps privacy", systemImage: "info.circle")
+ .font(.headline)
+ }
+ }
+
+ 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)
+ return HStack(spacing: 10) {
+ Image(serverOperator.largeLogo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .frame(height: 48)
+ Spacer()
+ Image(systemName: icon)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 26, height: 26)
+ .foregroundColor(iconColor)
+ }
+ .background(theme.colors.background)
+ .padding()
+ .clipShape(RoundedRectangle(cornerRadius: 18))
+ .overlay(
+ RoundedRectangle(cornerRadius: 18)
+ .stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2)
+ )
+ .padding(.horizontal, 2)
+ .onTapGesture {
+ if checked {
+ selectedOperatorIds.remove(serverOperator.operatorId)
+ } else {
+ selectedOperatorIds.insert(serverOperator.operatorId)
+ }
+ }
+ }
+
+ private func setOperatorsButton() -> some View {
+ Button {
+ dismiss()
+ } label: {
+ Text("OK")
+ }
+ .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
+ .disabled(selectedOperatorIds.isEmpty)
+ }
+}
+
+let operatorsPostLink = URL(string: "https://simplex.chat/blog/20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.html")!
+
+struct ChooseServerOperatorsInfoView: View {
+ @Environment(\.colorScheme) var colorScheme: ColorScheme
+ @EnvironmentObject var theme: AppTheme
+
+ var body: some View {
+ NavigationView {
+ List {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("The app protects your privacy by using different operators in each conversation.")
+ Text("When more than one operator is enabled, none of them has metadata to learn who communicates with whom.")
+ Text("For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server.")
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
+ .padding(.top)
+
+ Section {
+ ForEach(ChatModel.shared.conditions.serverOperators) { op in
+ operatorInfoNavLinkView(op)
+ }
+ } header: {
+ Text("About operators")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+ .navigationTitle("Server operators")
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+
+ private func operatorInfoNavLinkView(_ op: ServerOperator) -> some View {
+ NavigationLink() {
+ OperatorInfoView(serverOperator: op)
+ .navigationBarTitle("Network operator")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ HStack {
+ Image(op.logo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .frame(width: 24, height: 24)
+ Text(op.tradeName)
+ }
+ }
+ }
+}
+
+#Preview {
+ OnboardingConditionsView()
+}
diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
index 487f4ccdeb..ae72cb1be5 100644
--- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
+++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
@@ -38,7 +38,7 @@ struct CreateProfile: View {
TextField("Enter your name…", text: $displayName)
.focused($focusDisplayName)
Button {
- createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss)
+ createProfile()
} label: {
Label("Create profile", systemImage: "checkmark")
}
@@ -62,8 +62,7 @@ struct CreateProfile: View {
.frame(height: 20)
} footer: {
VStack(alignment: .leading, spacing: 8) {
- Text("Your profile, contacts and delivered messages are stored on your device.")
- Text("The profile is only shared with your contacts.")
+ Text("Your profile is stored on your device and only shared with your contacts.")
}
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -78,6 +77,35 @@ struct CreateProfile: View {
}
}
}
+
+ private func createProfile() {
+ hideKeyboard()
+ let profile = Profile(
+ displayName: displayName.trimmingCharacters(in: .whitespaces),
+ fullName: ""
+ )
+ let m = ChatModel.shared
+ do {
+ AppChatState.shared.set(.active)
+ m.currentUser = try apiCreateActiveUser(profile)
+ // .isEmpty check is redundant here, but it makes it clearer what is going on
+ if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) {
+ try startChat()
+ withAnimation {
+ onboardingStageDefault.set(.step3_ChooseServerOperators)
+ m.onboardingStage = .step3_ChooseServerOperators
+ }
+ } else {
+ onboardingStageDefault.set(.onboardingComplete)
+ m.onboardingStage = .onboardingComplete
+ dismiss()
+ m.users = try listUsers()
+ try getUserChatData()
+ }
+ } catch let error {
+ showCreateProfileAlert(showAlert: { alert = $0 }, error)
+ }
+ }
}
struct CreateFirstProfile: View {
@@ -86,131 +114,158 @@ struct CreateFirstProfile: View {
@Environment(\.dismiss) var dismiss
@State private var displayName: String = ""
@FocusState private var focusDisplayName
+ @State private var nextStepNavLinkActive = false
var body: some View {
- VStack(alignment: .leading) {
- Group {
- Text("Create your profile")
+ let v = VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .center, spacing: 16) {
+ Text("Create 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)
+ .multilineTextAlignment(.center)
- ZStack(alignment: .topLeading) {
+ Text("Your profile is stored on your device and only shared with your contacts.")
+ .font(.callout)
+ .foregroundColor(theme.colors.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity) // Ensures it takes up the full width
+ .padding(.horizontal, 10)
+ .onTapGesture { focusDisplayName = false }
+
+ 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(alignment: .trailing) {
+ TextField("Enter your name…", text: $displayName)
+ .focused($focusDisplayName)
+ .padding(.horizontal)
+ .padding(.trailing, 20)
+ .padding(.vertical, 10)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(Color(uiColor: .tertiarySystemFill))
+ )
+ if name != validName {
+ Button {
+ showAlert(.invalidNameError(validName: validName))
+ } label: {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundColor(.red)
+ .padding(.horizontal, 10)
+ }
}
- } else {
- Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
}
- TextField("Enter your name…", text: $displayName)
- .focused($focusDisplayName)
- .padding(.leading, 32)
}
- .padding(.bottom)
+ .padding(.top)
+
Spacer()
- onboardingButtons()
+
+ VStack(spacing: 10) {
+ createProfileButton()
+ if !focusDisplayName {
+ onboardingButtonPlaceholder()
+ }
+ }
}
.onAppear() {
- focusDisplayName = true
- setLastVersionDefault()
+ if #available(iOS 16, *) {
+ focusDisplayName = true
+ } else {
+ // it does not work before animation completes on iOS 15
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ focusDisplayName = true
+ }
+ }
}
- .padding()
+ .padding(.horizontal, 25)
+ .padding(.bottom, 25)
.frame(maxWidth: .infinity, alignment: .leading)
+ if #available(iOS 16, *) {
+ return v.padding(.top, 10)
+ } else {
+ return v.padding(.top, 75).ignoresSafeArea(.all, edges: .top)
+ }
}
- func onboardingButtons() -> some View {
- HStack {
+ func createProfileButton() -> some View {
+ ZStack {
Button {
- hideKeyboard()
- withAnimation {
- m.onboardingStage = .step1_SimpleXInfo
- }
+ createProfile()
} label: {
- HStack {
- Image(systemName: "lessthan")
- Text("About SimpleX")
- }
- }
-
- Spacer()
-
- Button {
- createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
- } label: {
- HStack {
- Text("Create")
- Image(systemName: "greaterthan")
- }
+ Text("Create profile")
}
+ .buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName)))
.disabled(!canCreateProfile(displayName))
+
+ NavigationLink(isActive: $nextStepNavLinkActive) {
+ nextStepDestinationView()
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
}
}
private func showAlert(_ alert: UserProfileAlert) {
AlertManager.shared.showAlert(userProfileAlert(alert, $displayName))
}
+
+ private func nextStepDestinationView() -> some View {
+ OnboardingConditionsView()
+ .navigationBarBackButtonHidden(true)
+ .modifier(ThemedBackground())
+ }
+
+ private func createProfile() {
+ hideKeyboard()
+ let profile = Profile(
+ displayName: displayName.trimmingCharacters(in: .whitespaces),
+ fullName: ""
+ )
+ let m = ChatModel.shared
+ do {
+ AppChatState.shared.set(.active)
+ m.currentUser = try apiCreateActiveUser(profile)
+ try startChat(onboarding: true)
+ onboardingStageDefault.set(.step3_ChooseServerOperators)
+ nextStepNavLinkActive = true
+ } catch let error {
+ showCreateProfileAlert(showAlert: showAlert, error)
+ }
+ }
}
-private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) {
- hideKeyboard()
- let profile = Profile(
- displayName: displayName.trimmingCharacters(in: .whitespaces),
- fullName: ""
- )
+private func showCreateProfileAlert(
+ showAlert: (UserProfileAlert) -> Void,
+ _ error: Error
+) {
let m = ChatModel.shared
- do {
- AppChatState.shared.set(.active)
- m.currentUser = try apiCreateActiveUser(profile)
- // .isEmpty check is redundant here, but it makes it clearer what is going on
- if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) {
- try startChat()
- withAnimation {
- onboardingStageDefault.set(.step3_CreateSimpleXAddress)
- m.onboardingStage = .step3_CreateSimpleXAddress
- }
+ switch error as? ChatError {
+ case .errorStore(.duplicateName),
+ .error(.userExists):
+ if m.currentUser == nil {
+ AlertManager.shared.showAlert(duplicateUserAlert)
} else {
- onboardingStageDefault.set(.onboardingComplete)
- m.onboardingStage = .onboardingComplete
- dismiss()
- m.users = try listUsers()
- try getUserChatData()
+ showAlert(.duplicateUserError)
}
- } catch let error {
- switch error as? ChatResponse {
- case .chatCmdError(_, .errorStore(.duplicateName)),
- .chatCmdError(_, .error(.userExists)):
- if m.currentUser == nil {
- AlertManager.shared.showAlert(duplicateUserAlert)
- } else {
- showAlert(.duplicateUserError)
- }
- case .chatCmdError(_, .error(.invalidDisplayName)):
- if m.currentUser == nil {
- AlertManager.shared.showAlert(invalidDisplayNameAlert)
- } else {
- showAlert(.invalidDisplayNameError)
- }
- default:
- let err: LocalizedStringKey = "Error: \(responseError(error))"
- if m.currentUser == nil {
- AlertManager.shared.showAlert(creatUserErrorAlert(err))
- } else {
- showAlert(.createUserError(error: err))
- }
+ case .error(.invalidDisplayName):
+ if m.currentUser == nil {
+ AlertManager.shared.showAlert(invalidDisplayNameAlert)
+ } else {
+ showAlert(.invalidDisplayNameError)
+ }
+ default:
+ let err: LocalizedStringKey = "Error: \(responseError(error))"
+ if m.currentUser == nil {
+ AlertManager.shared.showAlert(creatUserErrorAlert(err))
+ } else {
+ showAlert(.createUserError(error: err))
}
- logger.error("Failed to create user or start chat: \(responseError(error))")
}
+ logger.error("Failed to create user or start chat: \(responseError(error))")
}
private func canCreateProfile(_ displayName: String) -> Bool {
diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift
index befb34b318..a2f5db7f03 100644
--- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift
+++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift
@@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View {
Spacer()
if let userAddress = m.userAddress {
- SimpleXLinkQRCode(uri: userAddress.connReqContact)
+ SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: Binding.constant(false))
.frame(maxHeight: g.size.width)
shareQRCodeButton(userAddress)
.frame(maxWidth: .infinity)
@@ -77,9 +77,9 @@ struct CreateSimpleXAddress: View {
progressIndicator = true
Task {
do {
- let connReqContact = try await apiCreateUserAddress()
+ let connLinkContact = try await apiCreateUserAddress(short: false)
DispatchQueue.main.async {
- m.userAddress = UserContactLink(connReqContact: connReqContact)
+ m.userAddress = UserContactLink(connLinkContact: connLinkContact)
}
await MainActor.run { progressIndicator = false }
} catch let error {
@@ -121,7 +121,7 @@ struct CreateSimpleXAddress: View {
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
Button {
- showShareSheet(items: [simplexChatLink(userAddress.connReqContact)])
+ showShareSheet(items: [simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: false))])
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
@@ -189,7 +189,7 @@ struct SendAddressMailView: View {
let messageBody = String(format: NSLocalizedString("""
Hi!
Connect to me via SimpleX Chat
- """, comment: "email text"), simplexChatLink(userAddress.connReqContact))
+ """, comment: "email text"), simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: false)))
MailView(
isShowing: self.$showMailView,
result: $mailViewResult,
diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift
index c1975765d2..7452d74e91 100644
--- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift
+++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift
@@ -9,24 +9,24 @@
import SwiftUI
struct HowItWorks: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var m: ChatModel
var onboarding: Bool
+ @Binding var createProfileNavLinkActive: Bool
var body: some View {
VStack(alignment: .leading) {
Text("How SimpleX works")
.font(.largeTitle)
+ .bold()
.padding(.vertical)
ScrollView {
VStack(alignment: .leading) {
Group {
- Text("Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*")
- Text("To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.")
- Text("You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.")
- Text("Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.")
- if onboarding {
- Text("Read more in our GitHub repository.")
- } else {
+ Text("To protect your privacy, SimpleX uses separate IDs for each of your contacts.")
+ Text("Only client devices store user profiles, contacts, groups, and messages.")
+ Text("All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages.")
+ if !onboarding {
Text("Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).")
}
}
@@ -37,19 +37,34 @@ struct HowItWorks: View {
Spacer()
if onboarding {
- OnboardingActionButton()
- .padding(.bottom, 8)
+ VStack(spacing: 10) {
+ createFirstProfileButton()
+ onboardingButtonPlaceholder()
+ }
}
}
.lineLimit(10)
- .padding()
+ .padding(onboarding ? 25 : 16)
.frame(maxHeight: .infinity, alignment: .top)
.modifier(ThemedBackground())
}
+
+ private func createFirstProfileButton() -> some View {
+ Button {
+ dismiss()
+ createProfileNavLinkActive = true
+ } label: {
+ Text("Create your profile")
+ }
+ .buttonStyle(OnboardingButtonStyle(isDisabled: false))
+ }
}
struct HowItWorks_Previews: PreviewProvider {
static var previews: some View {
- HowItWorks(onboarding: true)
+ HowItWorks(
+ onboarding: true,
+ createProfileNavLinkActive: Binding.constant(false)
+ )
}
}
diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift
index 438491b5f1..8f448dc508 100644
--- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift
+++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift
@@ -12,20 +12,39 @@ struct OnboardingView: View {
var onboarding: OnboardingStage
var body: some View {
- switch onboarding {
- case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
- case .step2_CreateProfile: CreateFirstProfile()
- case .step3_CreateSimpleXAddress: CreateSimpleXAddress()
- case .step4_SetNotificationsMode: SetNotificationsMode()
- case .onboardingComplete: EmptyView()
+ NavigationView {
+ switch onboarding {
+ case .step1_SimpleXInfo:
+ SimpleXInfo(onboarding: true)
+ .modifier(ThemedBackground())
+ case .step2_CreateProfile: // deprecated
+ CreateFirstProfile()
+ .modifier(ThemedBackground())
+ case .step3_CreateSimpleXAddress: // deprecated
+ CreateSimpleXAddress()
+ case .step3_ChooseServerOperators:
+ OnboardingConditionsView()
+ .navigationBarBackButtonHidden(true)
+ .modifier(ThemedBackground())
+ case .step4_SetNotificationsMode:
+ SetNotificationsMode()
+ .navigationBarBackButtonHidden(true)
+ .modifier(ThemedBackground())
+ case .onboardingComplete: EmptyView()
+ }
}
}
}
+func onboardingButtonPlaceholder() -> some View {
+ Spacer().frame(height: 40)
+}
+
enum OnboardingStage: String, Identifiable {
case step1_SimpleXInfo
- case step2_CreateProfile
- case step3_CreateSimpleXAddress
+ case step2_CreateProfile // deprecated
+ case step3_CreateSimpleXAddress // deprecated
+ case step3_ChooseServerOperators // changed to simplified conditions
case step4_SetNotificationsMode
case onboardingComplete
diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift
index 7681a42a77..31865e7af9 100644
--- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift
+++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift
@@ -13,43 +13,61 @@ struct SetNotificationsMode: View {
@EnvironmentObject var m: ChatModel
@State private var notificationMode = NotificationsMode.instant
@State private var showAlert: NotificationAlert?
+ @State private var showInfo: Bool = false
var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- Text("Push notifications")
- .font(.largeTitle)
- .bold()
- .frame(maxWidth: .infinity)
+ GeometryReader { g in
+ let v = ScrollView {
+ VStack(alignment: .center, spacing: 20) {
+ Text("Push notifications")
+ .font(.largeTitle)
+ .bold()
+ .padding(.top, 25)
+
+ infoText()
+
+ Spacer()
- 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!")
+ 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()
+
+ VStack(spacing: 10) {
+ 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())
+ onboardingButtonPlaceholder()
}
}
- .font(.title)
- .frame(maxWidth: .infinity)
+ .padding(25)
+ .frame(minHeight: g.size.height)
+ }
+ if #available(iOS 16.4, *) {
+ v.scrollBounceBehavior(.basedOnSize)
+ } else {
+ v
}
- .padding()
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
+ .frame(maxHeight: .infinity)
+ .sheet(isPresented: $showInfo) {
+ NotificationsInfoView()
+ }
+ .navigationBarHidden(true) // necessary on iOS 15
}
private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) {
@@ -75,6 +93,15 @@ struct SetNotificationsMode: View {
}
}
}
+
+ private func infoText() -> some View {
+ Button {
+ showInfo = true
+ } label: {
+ Label("How it affects privacy", systemImage: "info.circle")
+ .font(.headline)
+ }
+ }
}
struct NtfModeSelector: View {
@@ -85,15 +112,25 @@ struct NtfModeSelector: View {
var body: some View {
ZStack {
- VStack(alignment: .leading, spacing: 4) {
- Text(mode.label)
- .font(.headline)
+ HStack(spacing: 16) {
+ Image(systemName: mode.icon)
+ .resizable()
+ .scaledToFill()
+ .frame(width: mode.icon == "bolt" ? 14 : 18, height: 18)
.foregroundColor(selection == mode ? theme.colors.primary : theme.colors.secondary)
- Text(ntfModeDescription(mode))
- .lineLimit(10)
- .font(.subheadline)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(mode.label)
+ .font(.headline)
+ .foregroundColor(selection == mode ? theme.colors.primary : theme.colors.secondary)
+ Text(ntfModeShortDescription(mode))
+ .lineLimit(2)
+ .font(.callout)
+ .fixedSize(horizontal: false, vertical: true)
+ }
}
- .padding(12)
+ .padding(.vertical, 12)
+ .padding(.trailing, 12)
+ .padding(.leading, 16)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(tapped ? Color(uiColor: .secondarySystemFill) : theme.colors.background)
@@ -109,6 +146,37 @@ struct NtfModeSelector: View {
}
}
+struct NotificationsInfoView: View {
+ var body: some View {
+ VStack(alignment: .leading) {
+ Text("Notifications privacy")
+ .font(.largeTitle)
+ .bold()
+ .padding(.vertical)
+ ScrollView {
+ VStack(alignment: .leading) {
+ Group {
+ ForEach(NotificationsMode.values) { mode in
+ VStack(alignment: .leading, spacing: 4) {
+ (Text(Image(systemName: mode.icon)) + textSpace + Text(mode.label))
+ .font(.headline)
+ .foregroundColor(.secondary)
+ Text(ntfModeDescription(mode))
+ .lineLimit(10)
+ .font(.callout)
+ }
+ }
+ }
+ .padding(.bottom)
+ }
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .modifier(ThemedBackground())
+ }
+}
+
struct NotificationsModeView_Previews: PreviewProvider {
static var previews: some View {
SetNotificationsMode()
diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
index ee5a618e68..9f41a37b1d 100644
--- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
+++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
@@ -13,59 +13,62 @@ 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 {
+ let v = 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)
-
- 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: .center, spacing: 10) {
+ Image(colorScheme == .light ? "logo" : "logo-light")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: g.size.width * 0.67)
+ .padding(.bottom, 8)
+ .padding(.leading, 4)
+ .frame(maxWidth: .infinity, minHeight: 48, alignment: .top)
+
+ Button {
+ showHowItWorks = true
+ } label: {
+ Label("The future of messaging", systemImage: "info.circle")
+ .font(.headline)
+ }
}
Spacer()
+
+ VStack(alignment: .leading) {
+ onboardingInfoRow("privacy", "Privacy redefined",
+ "No user identifiers.", width: 48)
+ onboardingInfoRow("shield", "Immune to spam",
+ "You decide who can connect.", width: 46)
+ onboardingInfoRow(colorScheme == .light ? "decentralized" : "decentralized-light", "Decentralized",
+ "Anybody can host servers.", width: 46)
+ }
+ .padding(.leading, 16)
+
+ Spacer()
+
if onboarding {
- OnboardingActionButton()
- Spacer()
+ VStack(spacing: 10) {
+ createFirstProfileButton()
- Button {
- m.migrationState = .pasteOrScanLink
- } label: {
- Label("Migrate from another device", systemImage: "tray.and.arrow.down")
- .font(.subheadline)
+ Button {
+ m.migrationState = .pasteOrScanLink
+ } label: {
+ Label("Migrate from another device", systemImage: "tray.and.arrow.down")
+ .font(.system(size: 17, weight: .semibold))
+ .frame(minHeight: 40)
+ }
+ .frame(maxWidth: .infinity)
}
- .padding(.bottom, 8)
- .frame(maxWidth: .infinity)
}
-
- Button {
- showHowItWorks = true
- } label: {
- Label("How it works", systemImage: "info.circle")
- .font(.subheadline)
- }
- .padding(.bottom, 8)
- .frame(maxWidth: .infinity)
-
}
+ .padding(.horizontal, 25)
+ .padding(.top, 75)
+ .padding(.bottom, 25)
.frame(minHeight: g.size.height)
}
.sheet(isPresented: Binding(
@@ -83,76 +86,68 @@ struct SimpleXInfo: View {
}
}
.sheet(isPresented: $showHowItWorks) {
- HowItWorks(onboarding: onboarding)
+ HowItWorks(
+ onboarding: onboarding,
+ createProfileNavLinkActive: $createProfileNavLinkActive
+ )
+ }
+ if #available(iOS 16.4, *) {
+ v.scrollBounceBehavior(.basedOnSize)
+ } else {
+ v
}
}
+ .onAppear() {
+ setLastVersionDefault()
+ }
.frame(maxHeight: .infinity)
- .padding()
+ .navigationBarHidden(true) // necessary on iOS 15
}
- private func infoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
+ private func onboardingInfoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
HStack(alignment: .top) {
Image(image)
.resizable()
.scaledToFit()
.frame(width: width, height: 54)
.frame(width: 54)
- .padding(.top, 4)
- .padding(.leading, 4)
.padding(.trailing, 10)
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(text).frame(minHeight: 40, alignment: .top)
+ .font(.callout)
+ .lineLimit(3)
+ .fixedSize(horizontal: false, vertical: true)
}
+ .padding(.top, 4)
+ }
+ .padding(.bottom, 12)
+ }
+
+ private func createFirstProfileButton() -> some View {
+ ZStack {
+ Button {
+ createProfileNavLinkActive = true
+ } label: {
+ Text("Create your profile")
+ }
+ .buttonStyle(OnboardingButtonStyle(isDisabled: false))
+
+ NavigationLink(isActive: $createProfileNavLinkActive) {
+ CreateFirstProfile()
+ .modifier(ThemedBackground())
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
}
- .padding(.bottom, 20)
- .padding(.trailing, 6)
}
}
-struct OnboardingActionButton: View {
- @EnvironmentObject var m: ChatModel
- @Environment(\.colorScheme) var colorScheme
+let textSpace = Text(verbatim: " ")
- var body: some View {
- if m.currentUser == nil {
- actionButton("Create your profile", onboarding: .step2_CreateProfile)
- } else {
- actionButton("Make a private connection", onboarding: .onboardingComplete)
- }
- }
-
- private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View {
- Button {
- withAnimation {
- onboardingStageDefault.set(onboarding)
- m.onboardingStage = onboarding
- }
- } label: {
- HStack {
- Text(label).font(.title2)
- Image(systemName: "greaterthan")
- }
- }
- .frame(maxWidth: .infinity)
- .padding(.bottom)
- }
-
- private func actionButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
- Button {
- withAnimation {
- action()
- }
- } label: {
- HStack {
- Text(label).font(.title2)
- Image(systemName: "greaterthan")
- }
- }
- .frame(maxWidth: .infinity)
- .padding(.bottom)
- }
-}
+let textNewLine = Text(verbatim: "\n")
struct SimpleXInfo_Previews: PreviewProvider {
static var previews: some View {
diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
index ed3adcfe7d..f65a21623a 100644
--- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
+++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
@@ -7,190 +7,209 @@
//
import SwiftUI
+import SimpleXChat
private struct VersionDescription {
var version: String
var post: URL?
- var features: [FeatureDescription]
+ var features: [Feature]
}
-private struct FeatureDescription {
- var icon: String?
- var title: LocalizedStringKey
- var description: LocalizedStringKey?
+private enum Feature: Identifiable {
+ case feature(Description)
+ case view(FeatureView)
+
+ var id: LocalizedStringKey {
+ switch self {
+ case let .feature(d): d.title
+ case let .view(v): v.title
+ }
+ }
+}
+
+private struct Description {
+ let icon: String?
+ let title: LocalizedStringKey
+ let description: LocalizedStringKey?
var subfeatures: [(icon: String, description: LocalizedStringKey)] = []
}
+private struct FeatureView {
+ let icon: String?
+ let title: LocalizedStringKey
+ let view: () -> any View
+}
+
private let versionDescriptions: [VersionDescription] = [
VersionDescription(
version: "v4.2",
post: URL(string: "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "checkmark.shield",
title: "Security assessment",
description: "SimpleX Chat security was audited by Trail of Bits."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "person.2",
title: "Group links",
description: "Admins can create the links to join groups."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "checkmark",
title: "Auto-accept contact requests",
description: "With optional welcome message."
- ),
+ )),
]
),
VersionDescription(
version: "v4.3",
post: URL(string: "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "mic",
title: "Voice messages",
description: "Max 30 seconds, received instantly."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "trash.slash",
title: "Irreversible message deletion",
description: "Your contacts can allow full message deletion."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "externaldrive.connected.to.line.below",
title: "Improved server configuration",
description: "Add servers by scanning QR codes."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "eye.slash",
title: "Improved privacy and security",
description: "Hide app screen in the recent apps."
- ),
+ )),
]
),
VersionDescription(
version: "v4.4",
post: URL(string: "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "stopwatch",
title: "Disappearing messages",
description: "Sent messages will be deleted after set time."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "ellipsis.circle",
title: "Live messages",
description: "Recipients see updates as you type them."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "checkmark.shield",
title: "Verify connection security",
description: "Compare security codes with your contacts."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "camera",
title: "GIFs and stickers",
description: "Send them from gallery or custom keyboards."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "French interface",
description: "Thanks to the users – contribute via Weblate!"
- )
+ )),
]
),
VersionDescription(
version: "v4.5",
post: URL(string: "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "person.crop.rectangle.stack",
title: "Multiple chat profiles",
description: "Different names, avatars and transport isolation."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "rectangle.and.pencil.and.ellipsis",
title: "Message draft",
description: "Preserve the last message draft, with attachments."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "network.badge.shield.half.filled",
title: "Transport isolation",
description: "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "lock.doc",
title: "Private filenames",
description: "To protect timezone, image/voice files use UTC."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "battery.25",
title: "Reduced battery usage",
description: "More improvements are coming soon!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Italian interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- )
+ )),
]
),
VersionDescription(
version: "v4.6",
post: URL(string: "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "lock",
title: "Hidden chat profiles",
description: "Protect your chat profiles with a password!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "phone.arrow.up.right",
title: "Audio and video calls",
description: "Fully re-implemented - work in background!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "flag",
title: "Group moderation",
description: "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "plus.message",
title: "Group welcome message",
description: "Set the message shown to new members!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "battery.50",
title: "Further reduced battery usage",
description: "More improvements are coming soon!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Chinese and Spanish interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.0",
post: URL(string: "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "arrow.up.doc",
title: "Videos and files up to 1gb",
description: "Fast and no wait until the sender is online!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "lock",
title: "App passcode",
description: "Set it instead of system authentication."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Polish interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- ),
+ )),
]
),
// Also
@@ -200,240 +219,240 @@ private let versionDescriptions: [VersionDescription] = [
version: "v5.1",
post: URL(string: "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "face.smiling",
title: "Message reactions",
description: "Finally, we have them! 🚀"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "arrow.up.message",
title: "Better messages",
description: "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "lock",
title: "Self-destruct passcode",
description: "All data is erased when it is entered."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Japanese interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.2",
post: URL(string: "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "checkmark",
title: "Message delivery receipts!",
description: "The second tick we missed! ✅"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "star",
title: "Find chats faster",
description: "Filter unread and favorite chats."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "exclamationmark.arrow.triangle.2.circlepath",
title: "Keep your connections",
description: "Fix encryption after restoring backups."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "stopwatch",
title: "Make one message disappear",
description: "Even when disabled in the conversation."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "gift",
title: "A few more things",
description: "- more stable message delivery.\n- a bit better groups.\n- and more!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.3",
post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "desktopcomputer",
title: "New desktop app!",
description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "lock",
title: "Encrypt stored files & media",
description: "App encrypts new local files (except videos)."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "magnifyingglass",
title: "Discover and join groups",
description: "- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "theatermasks",
title: "Simplified incognito mode",
description: "Toggle incognito when connecting."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "\(4) new interface languages",
description: "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.4",
post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "desktopcomputer",
title: "Link mobile and desktop apps! 🔗",
description: "Via secure quantum resistant protocol."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "person.2",
title: "Better groups",
description: "Faster joining and more reliable messages."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "theatermasks",
title: "Incognito groups",
description: "Create a group using a random profile."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "hand.raised",
title: "Block group members",
description: "To hide unwanted messages."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "gift",
title: "A few more things",
description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.5",
post: URL(string: "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "folder",
title: "Private notes",
description: "With encrypted files and media."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "link",
title: "Paste link to connect!",
description: "Search bar accepts invitation links."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "bubble.left.and.bubble.right",
title: "Join group conversations",
description: "Recent history and improved [directory bot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "battery.50",
title: "Improved message delivery",
description: "With reduced battery usage."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Turkish interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.6",
post: URL(string: "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "key",
title: "Quantum resistant encryption",
description: "Enable in direct chats (BETA)!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "tray.and.arrow.up",
title: "App data migration",
description: "Migrate to another device via QR code."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "phone",
title: "Picture-in-picture calls",
description: "Use the app while in the call."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "hand.raised",
title: "Safer groups",
description: "Admins can block a member for all."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "character",
title: "Hungarian interface",
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
- ),
+ )),
]
),
VersionDescription(
version: "v5.7",
post: URL(string: "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "key",
title: "Quantum resistant encryption",
description: "Will be enabled in direct chats!"
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "arrowshape.turn.up.forward",
title: "Forward and save messages",
description: "Message source remains private."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "music.note",
title: "In-call sounds",
description: "When connecting audio and video calls."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "person.crop.square",
title: "Shape profile images",
description: "Square, circle, or anything in between."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "antenna.radiowaves.left.and.right",
title: "Network management",
description: "More reliable network connection."
- )
+ )),
]
),
VersionDescription(
version: "v5.8",
post: URL(string: "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: "arrow.forward",
title: "Private message routing 🚀",
description: "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "network.badge.shield.half.filled",
title: "Safely receive files",
description: "Confirm files from unknown servers."
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: "battery.50",
title: "Improved message delivery",
description: "With reduced battery usage."
- )
+ )),
]
),
VersionDescription(
version: "v6.0",
post: URL(string: "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html"),
features: [
- FeatureDescription(
+ .feature(Description(
icon: nil,
title: "New chat experience 🎉",
description: nil,
@@ -444,8 +463,8 @@ private let versionDescriptions: [VersionDescription] = [
("platter.filled.bottom.and.arrow.down.iphone", "Use the app with one hand."),
("paintpalette", "Color chats with the new themes."),
]
- ),
- FeatureDescription(
+ )),
+ .feature(Description(
icon: nil,
title: "New media options",
description: nil,
@@ -454,17 +473,110 @@ 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: [
+ .feature(Description(
+ icon: "checkmark.shield",
+ title: "Better security ✅",
+ description: "SimpleX protocols reviewed by Trail of Bits."
+ )),
+ .feature(Description(
+ icon: "video",
+ title: "Better calls",
+ description: "Switch audio and video during the call."
+ )),
+ .feature(Description(
+ icon: "bolt",
+ title: "Better notifications",
+ description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!"
+ )),
+ .feature(Description(
+ icon: nil,
+ title: "Better user experience",
+ description: nil,
+ subfeatures: [
+ ("link", "Switch chat profile for 1-time invitations."),
+ ("message", "Customizable message shape."),
+ ("calendar", "Better message dates."),
+ ("arrowshape.turn.up.right", "Forward up to 20 messages at once."),
+ ("flag", "Delete or moderate up to 200 messages.")
+ ]
+ )),
+ ]
+ ),
+ VersionDescription(
+ version: "v6.2",
+ post: URL(string: "https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html"),
+ features: [
+ .view(FeatureView(
+ icon: nil,
+ title: "Network decentralization",
+ view: { NewOperatorsView() }
+ )),
+ .feature(Description(
+ icon: "briefcase",
+ title: "Business chats",
+ description: "Privacy for your customers."
+ )),
+ .feature(Description(
+ icon: "bolt",
+ title: "More reliable notifications",
+ description: "Delivered even when Apple drops them."
+ )),
+ ]
+ ),
+ VersionDescription(
+ version: "v6.3",
+ post: URL(string: "https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html"),
+ features: [
+ .feature(Description(
+ icon: "at",
+ title: "Mention members 👋",
+ description: "Get notified when mentioned."
+ )),
+ .feature(Description(
+ icon: "flag",
+ title: "Send private reports",
+ description: "Help admins moderating their groups."
+ )),
+ .feature(Description(
+ icon: "list.bullet",
+ title: "Organize chats into lists",
+ description: "Don't miss important messages."
+ )),
+ .feature(Description(
+ icon: nil,
+ title: "Better privacy and security",
+ description: nil,
+ subfeatures: [
+ ("eye.slash", "Private media file names."),
+ ("trash", "Set message expiration in chats.")
+ ]
+ )),
+ .feature(Description(
+ icon: nil,
+ title: "Better groups performance",
+ description: nil,
+ subfeatures: [
+ ("bolt", "Faster sending messages."),
+ ("person.2.slash", "Faster deletion of groups.")
+ ]
+ )),
]
),
]
@@ -481,14 +593,57 @@ func shouldShowWhatsNew() -> Bool {
return v != lastVersion
}
+fileprivate struct NewOperatorsView: View {
+ var body: some View {
+ VStack(alignment: .leading) {
+ Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo)
+ .resizable()
+ .scaledToFit()
+ .frame(height: 48)
+ Text("The second preset operator in the app!")
+ .multilineTextAlignment(.leading)
+ .lineLimit(10)
+ HStack {
+ Text("Enable Flux in Network & servers settings for better metadata privacy.")
+ }
+ }
+ }
+}
+
+private enum WhatsNewViewSheet: Identifiable {
+ case showConditions
+
+ var id: String {
+ switch self {
+ case .showConditions: return "showConditions"
+ }
+ }
+}
+
struct WhatsNewView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@State var currentVersion = versionDescriptions.count - 1
@State var currentVersionNav = versionDescriptions.count - 1
var viaSettings = false
+ var updatedConditions: Bool
+ @State private var sheetItem: WhatsNewViewSheet? = nil
var body: some View {
+ whatsNewView()
+ .sheet(item: $sheetItem) { item in
+ switch item {
+ case .showConditions:
+ UsageConditionsView(
+ currUserServers: Binding.constant([]),
+ userServers: Binding.constant([])
+ )
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+ }
+
+ private func whatsNewView() -> some View {
VStack {
TabView(selection: $currentVersion) {
ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in
@@ -499,9 +654,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) {
@@ -511,13 +668,20 @@ struct WhatsNewView: View {
}
}
}
+ if updatedConditions {
+ Button("View updated conditions") {
+ sheetItem = .showConditions
+ }
+ }
if !viaSettings {
Spacer()
+
Button("Ok") {
dismiss()
}
.font(.title3)
.frame(maxWidth: .infinity, alignment: .center)
+
Spacer()
}
}
@@ -535,20 +699,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)
@@ -603,6 +771,6 @@ struct WhatsNewView: View {
struct NewFeaturesView_Previews: PreviewProvider {
static var previews: some View {
- WhatsNewView()
+ WhatsNewView(updatedConditions: false)
}
}
diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift
index b99c054abb..01b25baed8 100644
--- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift
+++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift
@@ -268,7 +268,7 @@ struct ConnectDesktopView: View {
private func ctrlDeviceNameText(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> Text {
var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo?.deviceName ?? "")
if (rc == nil) {
- t = t + Text(" ") + Text("(new)").italic()
+ t = t + textSpace + Text("(new)").italic()
}
return t
}
@@ -277,7 +277,7 @@ struct ConnectDesktopView: View {
let v = session.ctrlAppInfo?.appVersionRange.maxVersion
var t = Text("v\(v ?? "")")
if v != session.appVersion {
- t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic()
+ t = t + textSpace + Text("(this device v\(session.appVersion))").italic()
}
return t
}
@@ -456,12 +456,12 @@ struct ConnectDesktopView: View {
}
} catch let e {
await MainActor.run {
- switch e as? ChatResponse {
- case .chatCmdError(_, .errorRemoteCtrl(.badInvitation)): alert = .badInvitationError
- case .chatCmdError(_, .error(.commandError)): alert = .badInvitationError
- case let .chatCmdError(_, .errorRemoteCtrl(.badVersion(v))): alert = .badVersionError(version: v)
- case .chatCmdError(_, .errorAgent(.RCP(.version))): alert = .badVersionError(version: nil)
- case .chatCmdError(_, .errorAgent(.RCP(.ctrlAuth))): alert = .desktopDisconnectedError
+ switch e as? ChatError {
+ case .errorRemoteCtrl(.badInvitation): alert = .badInvitationError
+ case .error(.commandError): alert = .badInvitationError
+ case let .errorRemoteCtrl(.badVersion(v)): alert = .badVersionError(version: v)
+ case .errorAgent(.RCP(.version)): alert = .badVersionError(version: nil)
+ case .errorAgent(.RCP(.ctrlAuth)): alert = .desktopDisconnectedError
default: errorAlert(e)
}
}
diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift
index 36c05ed43d..554219eb69 100644
--- a/apps/ios/Shared/Views/TerminalView.swift
+++ b/apps/ios/Shared/Views/TerminalView.swift
@@ -18,7 +18,9 @@ struct TerminalView: View {
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State var composeState: ComposeState = ComposeState()
+ @State var selectedRange = NSRange()
@State private var keyboardVisible = false
+ @State private var keyboardHiddenDate = Date.now
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
@State private var terminalItem: TerminalItem?
@State private var scrolled = false
@@ -96,16 +98,23 @@ struct TerminalView: View {
SendMessageView(
composeState: $composeState,
+ selectedRange: $selectedRange,
sendMessage: { _ in consoleSendMessage() },
showVoiceMessageButton: false,
onMediaAdded: { _ in },
- keyboardVisible: $keyboardVisible
+ keyboardVisible: $keyboardVisible,
+ keyboardHiddenDate: $keyboardHiddenDate
)
.padding(.horizontal, 12)
}
}
.navigationViewStyle(.stack)
- .navigationTitle("Chat console")
+ .toolbar {
+ // Redaction broken for `.navigationTitle` - using a toolbar item instead.
+ ToolbarItem(placement: .principal) {
+ Text("Chat console").font(.headline)
+ }
+ }
.modifier(ThemedBackground())
}
@@ -136,18 +145,18 @@ struct TerminalView: View {
}
func consoleSendMessage() {
- let cmd = ChatCommand.string(composeState.message)
if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) {
- let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
+ let resp: APIResult = APIResult.error(ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
Task {
- await TerminalItems.shared.addCommand(.now, cmd, resp)
+ await TerminalItems.shared.addCommand(.now, .string(composeState.message), resp)
}
} else {
+ let cmd = composeState.message
DispatchQueue.global().async {
Task {
- composeState.inProgress = true
- _ = await chatSendCmd(cmd)
- composeState.inProgress = false
+ await MainActor.run { composeState.inProgress = true }
+ await sendTerminalCmd(cmd)
+ await MainActor.run { composeState.inProgress = false }
}
}
}
@@ -155,12 +164,38 @@ struct TerminalView: View {
}
}
+func sendTerminalCmd(_ cmd: String) async {
+ let start: Date = .now
+ await withCheckedContinuation { (cont: CheckedContinuation) in
+ let d = sendSimpleXCmdStr(cmd)
+ Task {
+ guard let d else {
+ await TerminalItems.shared.addCommand(start, ChatCommand.string(cmd), APIResult.error(.invalidJSON(json: nil)))
+ return
+ }
+ let r0: APIResult = decodeAPIResult(d)
+ guard case .invalid = r0 else {
+ await TerminalItems.shared.addCommand(start, .string(cmd), r0)
+ return
+ }
+ let r1: APIResult = decodeAPIResult(d)
+ guard case .invalid = r1 else {
+ await TerminalItems.shared.addCommand(start, .string(cmd), r1)
+ return
+ }
+ let r2: APIResult = decodeAPIResult(d)
+ await TerminalItems.shared.addCommand(start, .string(cmd), r2)
+ }
+ cont.resume(returning: ())
+ }
+}
+
struct TerminalView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.terminalItems = [
- .resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")),
- .resp(.now, ChatResponse.response(type: "newChatItems", json: "{}"))
+ .err(.now, APIResult.invalid(type: "contactSubscribed", json: "{}".data(using: .utf8)!).unexpected),
+ .err(.now, APIResult.invalid(type: "newChatItems", json: "{}".data(using: .utf8)!).unexpected)
]
return NavigationView {
TerminalView()
diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift
index aa7f885ac6..44e0b20958 100644
--- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift
@@ -65,6 +65,7 @@ extension AppSettings {
if let val = uiCurrentThemeIds { currentThemeIdsDefault.set(val) }
if let val = uiThemes { themeOverridesDefault.set(val.skipDuplicates()) }
if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) }
+ if let val = chatBottomBar { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_CHAT_BOTTOM_BAR) }
}
public static var current: AppSettings {
@@ -100,6 +101,7 @@ extension AppSettings {
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
c.uiThemes = themeOverridesDefault.get()
c.oneHandUI = groupDefaults.bool(forKey: GROUP_DEFAULT_ONE_HAND_UI)
+ c.chatBottomBar = groupDefaults.bool(forKey: GROUP_DEFAULT_CHAT_BOTTOM_BAR)
return c
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift
index ab3388bfce..c6d0e27289 100644
--- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift
@@ -367,13 +367,13 @@ struct ChatThemePreview: View {
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack {
- ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
+ ChatItemView(chat: Chat.sampleData, chatItem: alice, scrollToItemId: { _ in })
.modifier(ChatItemClipped(alice, tailVisible: true))
Spacer()
}
HStack {
Spacer()
- ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
+ ChatItemView(chat: Chat.sampleData, chatItem: bob, scrollToItemId: { _ in })
.modifier(ChatItemClipped(bob, tailVisible: true))
.frame(alignment: .trailing)
}
@@ -583,11 +583,14 @@ struct CustomizeThemeView: View {
}
}
- ImportExportThemeSection(perChat: nil, perUser: nil, save: { theme in
+ ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: nil)
+ }
+ .modifier(
+ ThemeImporter(isPresented: $showFileImporter) { theme in
ThemeManager.saveAndApplyThemeOverrides(theme)
saveThemeToDatabase(nil)
- })
- }
+ }
+ )
/// When changing app theme, user overrides are hidden. User overrides will be returned back after closing Appearance screen, see ThemeDestinationPicker()
.interactiveDismissDisabled(true)
}
@@ -595,10 +598,9 @@ struct CustomizeThemeView: View {
struct ImportExportThemeSection: View {
@EnvironmentObject var theme: AppTheme
+ @Binding var showFileImporter: Bool
var perChat: ThemeModeOverride?
var perUser: ThemeModeOverrides?
- var save: (ThemeOverrides) -> Void
- @State private var showFileImporter = false
var body: some View {
Section {
@@ -626,39 +628,47 @@ struct ImportExportThemeSection: View {
} label: {
Text("Import theme").foregroundColor(theme.colors.primary)
}
- .fileImporter(
- isPresented: $showFileImporter,
- allowedContentTypes: [.data/*.plainText*/],
- allowsMultipleSelection: false
- ) { result in
- if case let .success(files) = result, let fileURL = files.first {
- do {
- var fileSize: Int? = nil
- if fileURL.startAccessingSecurityScopedResource() {
- let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
- fileSize = resourceValues.fileSize
- }
- if let fileSize = fileSize,
- // Same as Android/desktop
- fileSize <= 5_500_000 {
- if let string = try? String(contentsOf: fileURL, encoding: .utf8), let theme: ThemeOverrides = decodeYAML("themeId: \(UUID().uuidString)\n" + string) {
- save(theme)
- logger.error("Saved theme from file")
- } else {
- logger.error("Error decoding theme file")
- }
- fileURL.stopAccessingSecurityScopedResource()
- } else {
- fileURL.stopAccessingSecurityScopedResource()
- let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: 5_500_000, countStyle: .binary)
- AlertManager.shared.showAlertMsg(
- title: "Large file!",
- message: "Currently maximum supported file size is \(prettyMaxFileSize)."
- )
- }
- } catch {
- logger.error("Appearance fileImporter error \(error.localizedDescription)")
+ }
+ }
+}
+
+struct ThemeImporter: ViewModifier {
+ @Binding var isPresented: Bool
+ var save: (ThemeOverrides) -> Void
+
+ func body(content: Content) -> some View {
+ content.fileImporter(
+ isPresented: $isPresented,
+ allowedContentTypes: [.data/*.plainText*/],
+ allowsMultipleSelection: false
+ ) { result in
+ if case let .success(files) = result, let fileURL = files.first {
+ do {
+ var fileSize: Int? = nil
+ if fileURL.startAccessingSecurityScopedResource() {
+ let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
+ fileSize = resourceValues.fileSize
}
+ if let fileSize = fileSize,
+ // Same as Android/desktop
+ fileSize <= 5_500_000 {
+ if let string = try? String(contentsOf: fileURL, encoding: .utf8), let theme: ThemeOverrides = decodeYAML("themeId: \(UUID().uuidString)\n" + string) {
+ save(theme)
+ logger.error("Saved theme from file")
+ } else {
+ logger.error("Error decoding theme file")
+ }
+ fileURL.stopAccessingSecurityScopedResource()
+ } else {
+ fileURL.stopAccessingSecurityScopedResource()
+ let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: 5_500_000, countStyle: .binary)
+ AlertManager.shared.showAlertMsg(
+ title: "Large file!",
+ message: "Currently maximum supported file size is \(prettyMaxFileSize)."
+ )
+ }
+ } catch {
+ logger.error("Appearance fileImporter error \(error.localizedDescription)")
}
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
index 4ef05bd998..54454b7cef 100644
--- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
+++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
@@ -45,7 +45,7 @@ struct DeveloperView: View {
} header: {
Text("")
} footer: {
- ((developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option."))
+ ((developerTools ? Text("Show:") : Text("Hide:")) + textSpace + Text("Database IDs and Transport isolation option."))
.foregroundColor(theme.colors.secondary)
}
@@ -54,6 +54,13 @@ struct DeveloperView: View {
settingsRow("internaldrive", color: theme.colors.secondary) {
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
}
+ NavigationLink {
+ StorageView()
+ .navigationTitle("Storage")
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ settingsRow("internaldrive", color: theme.colors.secondary) { Text("Storage") }
+ }
} header: {
Text("Developer options")
}
diff --git a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift
index cf9cada592..71c284e9ab 100644
--- a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift
+++ b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift
@@ -19,7 +19,7 @@ struct MarkdownHelp: View {
mdFormat("_italic_", Text("italic").italic())
mdFormat("~strike~", Text("strike").strikethrough())
mdFormat("`a + b`", Text("`a + b`").font(.body.monospaced()))
- mdFormat("!1 colored!", Text("colored").foregroundColor(.red) + Text(" (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(")"))
+ mdFormat("!1 colored!", Text("colored").foregroundColor(.red) + Text(verbatim: " (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(verbatim: ")"))
(
mdFormat("#secret#", Text("secret")
.foregroundColor(.clear)
@@ -39,7 +39,7 @@ private func mdFormat(_ format: LocalizedStringKey, _ example: Text) -> some Vie
}
private func color(_ s: String, _ c: Color) -> Text {
- Text(s).foregroundColor(c) + Text(", ")
+ Text(s).foregroundColor(c) + Text(verbatim: ", ")
}
struct MarkdownHelp_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift
deleted file mode 100644
index 155a3956be..0000000000
--- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift
+++ /dev/null
@@ -1,84 +0,0 @@
-//
-// NetworkServersView.swift
-// SimpleX (iOS)
-//
-// Created by Evgeny on 02/08/2022.
-// Copyright © 2022 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-import SimpleXChat
-
-private enum NetworkAlert: Identifiable {
- case error(err: String)
-
- var id: String {
- switch self {
- case let .error(err): return "error \(err)"
- }
- }
-}
-
-struct NetworkAndServers: View {
- @EnvironmentObject var m: ChatModel
- @EnvironmentObject var theme: AppTheme
-
- var body: some View {
- VStack {
- List {
- Section {
- NavigationLink {
- ProtocolServersView(serverProtocol: .smp)
- .navigationTitle("Your SMP servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("Message servers")
- }
-
- NavigationLink {
- ProtocolServersView(serverProtocol: .xftp)
- .navigationTitle("Your XFTP servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("Media & file servers")
- }
-
- NavigationLink {
- AdvancedNetworkSettings()
- .navigationTitle("Advanced settings")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("Advanced network settings")
- }
- } header: {
- Text("Messages & files")
- .foregroundColor(theme.colors.secondary)
- }
-
- Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
- NavigationLink {
- RTCServers()
- .navigationTitle("Your ICE servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- Text("WebRTC ICE servers")
- }
- }
-
- Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) {
- HStack {
- Text(m.networkInfo.networkType.text)
- Spacer()
- Image(systemName: "circle.fill").foregroundColor(m.networkInfo.online ? .green : .red)
- }
- }
- }
- }
- }
-}
-
-struct NetworkServersView_Previews: PreviewProvider {
- static var previews: some View {
- NetworkAndServers()
- }
-}
diff --git a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift
similarity index 95%
rename from apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift
rename to apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift
index 754ca3cf6b..fa698f8b7c 100644
--- a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift
@@ -208,6 +208,19 @@ struct AdvancedNetworkSettings: View {
}
}
+ Section {
+ Picker("Use web port", selection: $netCfg.smpWebPortServers) {
+ ForEach(SMPWebPortServers.allCases, id: \.self) { Text($0.text) }
+ }
+ .frame(height: 36)
+ } header: {
+ Text("TCP port for messaging")
+ } footer: {
+ netCfg.smpWebPortServers == .preset
+ ? Text("Use TCP port 443 for preset servers only.")
+ : Text("Use TCP port \(netCfg.smpWebPortServers == .all ? "443" : "5223") when no port is specified.")
+ }
+
Section("TCP connection") {
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
@@ -360,8 +373,8 @@ struct AdvancedNetworkSettings: View {
let userMode = Text("A separate TCP connection will be used **for each chat profile you have in the app**.")
return switch mode {
case .user: userMode
- case .session: userMode + Text("\n") + Text("New SOCKS credentials will be used every time you start the app.")
- case .server: userMode + Text("\n") + Text("New SOCKS credentials will be used for each server.")
+ case .session: userMode + textNewLine + Text("New SOCKS credentials will be used every time you start the app.")
+ case .server: userMode + textNewLine + Text("New SOCKS credentials will be used for each server.")
case .entity: Text("A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.")
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift
new file mode 100644
index 0000000000..1e38b7d5ec
--- /dev/null
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift
@@ -0,0 +1,83 @@
+//
+// ConditionsWebView.swift
+// SimpleX (iOS)
+//
+// Created by Stanislav Dmitrenko on 26.11.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import WebKit
+
+struct ConditionsWebView: UIViewRepresentable {
+ @State var html: String
+ @EnvironmentObject var theme: AppTheme
+ @State var pageLoaded = false
+
+ func makeUIView(context: Context) -> WKWebView {
+ let view = WKWebView()
+ view.backgroundColor = .clear
+ view.isOpaque = false
+ view.navigationDelegate = context.coordinator
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ // just to make sure that even if updateUIView will not be called for any reason, the page
+ // will be rendered anyway
+ if !pageLoaded {
+ loadPage(view)
+ }
+ }
+ return view
+ }
+
+ func updateUIView(_ view: WKWebView, context: Context) {
+ loadPage(view)
+ }
+
+ private func loadPage(_ webView: WKWebView) {
+ let styles = """
+
+ """
+ let head = "\(styles)"
+ webView.loadHTMLString(head + html, baseURL: nil)
+ DispatchQueue.main.async {
+ pageLoaded = true
+ }
+ }
+
+ func makeCoordinator() -> Cordinator {
+ Cordinator()
+ }
+
+ class Cordinator: NSObject, WKNavigationDelegate {
+ func webView(_ webView: WKWebView,
+ decidePolicyFor navigationAction: WKNavigationAction,
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+
+ guard let url = navigationAction.request.url else { return decisionHandler(.allow) }
+
+ switch navigationAction.navigationType {
+ case .linkActivated:
+ decisionHandler(.cancel)
+ if url.absoluteString.starts(with: "https://simplex.chat/contact#") {
+ ChatModel.shared.appOpenUrl = url
+ } else {
+ UIApplication.shared.open(url)
+ }
+ default:
+ decisionHandler(.allow)
+ }
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift
new file mode 100644
index 0000000000..6f4710396a
--- /dev/null
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift
@@ -0,0 +1,480 @@
+//
+// NetworkServersView.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 02/08/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+private enum NetworkAlert: Identifiable {
+ case error(err: String)
+
+ var id: String {
+ switch self {
+ case let .error(err): return "error \(err)"
+ }
+ }
+}
+
+private enum NetworkAndServersSheet: Identifiable {
+ case showConditions
+
+ var id: String {
+ switch self {
+ case .showConditions: return "showConditions"
+ }
+ }
+}
+
+struct NetworkAndServers: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var m: ChatModel
+ @Environment(\.colorScheme) var colorScheme: ColorScheme
+ @EnvironmentObject var theme: AppTheme
+ @EnvironmentObject var ss: SaveableSettings
+ @State private var sheetItem: NetworkAndServersSheet? = nil
+ @State private var justOpened = true
+ @State private var showSaveDialog = false
+
+ var body: some View {
+ VStack {
+ List {
+ let conditionsAction = m.conditions.conditionsAction
+ let anyOperatorEnabled = ss.servers.userServers.contains(where: { $0.operator?.enabled ?? false })
+ Section {
+ ForEach(ss.servers.userServers.enumerated().map { $0 }, id: \.element.id) { idx, userOperatorServers in
+ if let serverOperator = userOperatorServers.operator {
+ serverOperatorView(idx, serverOperator)
+ } else {
+ EmptyView()
+ }
+ }
+
+ if let conditionsAction = conditionsAction, anyOperatorEnabled {
+ conditionsButton(conditionsAction)
+ }
+ } header: {
+ Text("Preset servers")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ switch conditionsAction {
+ case let .review(_, deadline, _):
+ if let deadline = deadline, anyOperatorEnabled {
+ Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).")
+ .foregroundColor(theme.colors.secondary)
+ }
+ default:
+ EmptyView()
+ }
+ }
+
+ Section {
+ if let idx = ss.servers.userServers.firstIndex(where: { $0.operator == nil }) {
+ NavigationLink {
+ YourServersView(
+ userServers: $ss.servers.userServers,
+ serverErrors: $ss.servers.serverErrors,
+ operatorIndex: idx
+ )
+ .navigationTitle("Your servers")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ HStack {
+ Text("Your servers")
+
+ if ss.servers.userServers[idx] != ss.servers.currUserServers[idx] {
+ Spacer()
+ unsavedChangesIndicator()
+ }
+ }
+ }
+ }
+
+ NavigationLink {
+ AdvancedNetworkSettings()
+ .navigationTitle("Advanced settings")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ Text("Advanced network settings")
+ }
+ } header: {
+ Text("Messages & files")
+ .foregroundColor(theme.colors.secondary)
+ }
+
+ Section {
+ Button("Save servers", action: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) })
+ .disabled(!serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors))
+ } footer: {
+ if let errStr = globalServersError(ss.servers.serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else if !ss.servers.serverErrors.isEmpty {
+ ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error"))
+ }
+ }
+
+ Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
+ NavigationLink {
+ RTCServers()
+ .navigationTitle("Your ICE servers")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ Text("WebRTC ICE servers")
+ }
+ }
+
+ Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) {
+ HStack {
+ Text(m.networkInfo.networkType.text)
+ Spacer()
+ Image(systemName: "circle.fill").foregroundColor(m.networkInfo.online ? .green : .red)
+ }
+ }
+ }
+ }
+ .task {
+ // this condition is needed to prevent re-setting the servers when exiting single server view
+ if justOpened {
+ do {
+ ss.servers.currUserServers = try await getUserServers()
+ ss.servers.userServers = ss.servers.currUserServers
+ ss.servers.serverErrors = []
+ } catch let error {
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error loading servers", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ justOpened = false
+ }
+ }
+ .modifier(BackButton(disabled: Binding.constant(false)) {
+ if serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors) {
+ showSaveDialog = true
+ } else {
+ dismiss()
+ }
+ })
+ .confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) {
+ Button("Save") {
+ saveServers($ss.servers.currUserServers, $ss.servers.userServers)
+ dismiss()
+ }
+ Button("Exit without saving") { dismiss() }
+ }
+ .sheet(item: $sheetItem) { item in
+ switch item {
+ case .showConditions:
+ UsageConditionsView(
+ currUserServers: $ss.servers.currUserServers,
+ userServers: $ss.servers.userServers
+ )
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+ }
+
+ private func serverOperatorView(_ operatorIndex: Int, _ serverOperator: ServerOperator) -> some View {
+ NavigationLink() {
+ OperatorView(
+ currUserServers: $ss.servers.currUserServers,
+ userServers: $ss.servers.userServers,
+ serverErrors: $ss.servers.serverErrors,
+ operatorIndex: operatorIndex,
+ useOperator: serverOperator.enabled
+ )
+ .navigationBarTitle("\(serverOperator.tradeName) servers")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ HStack {
+ Image(serverOperator.logo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .grayscale(serverOperator.enabled ? 0.0 : 1.0)
+ .frame(width: 24, height: 24)
+ Text(serverOperator.tradeName)
+ .foregroundColor(serverOperator.enabled ? theme.colors.onBackground : theme.colors.secondary)
+
+ if ss.servers.userServers[operatorIndex] != ss.servers.currUserServers[operatorIndex] {
+ Spacer()
+ unsavedChangesIndicator()
+ }
+ }
+ }
+ }
+
+ private func unsavedChangesIndicator() -> some View {
+ Image(systemName: "pencil")
+ .foregroundColor(theme.colors.secondary)
+ .symbolRenderingMode(.monochrome)
+ .frame(maxWidth: 24, maxHeight: 24, alignment: .center)
+ }
+
+ private func conditionsButton(_ conditionsAction: UsageConditionsAction) -> some View {
+ Button {
+ sheetItem = .showConditions
+ } label: {
+ switch conditionsAction {
+ case .review:
+ Text("Review conditions")
+ case .accepted:
+ Text("Accepted conditions")
+ }
+ }
+ }
+}
+
+struct UsageConditionsView: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var theme: AppTheme
+ @Binding var currUserServers: [UserOperatorServers]
+ @Binding var userServers: [UserOperatorServers]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ switch ChatModel.shared.conditions.conditionsAction {
+
+ case .none:
+ regularConditionsHeader()
+ .padding(.top)
+ .padding(.top)
+ ConditionsTextView()
+ .padding(.bottom)
+ .padding(.bottom)
+
+ case let .review(operators, deadline, _):
+ HStack {
+ Text("Updated conditions").font(.largeTitle).bold()
+ }
+ .padding(.top)
+ .padding(.top)
+
+ Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
+ ConditionsTextView()
+ VStack(spacing: 8) {
+ acceptConditionsButton(operators.map { $0.operatorId })
+ if let deadline = deadline {
+ Text("Conditions will be automatically accepted for enabled operators on: \(conditionsTimestamp(deadline)).")
+ .foregroundColor(theme.colors.secondary)
+ .font(.footnote)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.horizontal, 32)
+ conditionsDiffButton(.footnote)
+ } else {
+ conditionsDiffButton()
+ .padding(.top)
+ }
+ }
+ .padding(.bottom)
+ .padding(.bottom)
+
+
+ case let .accepted(operators):
+ regularConditionsHeader()
+ .padding(.top)
+ .padding(.top)
+ Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
+ ConditionsTextView()
+ .padding(.bottom)
+ .padding(.bottom)
+ }
+ }
+ .padding(.horizontal, 25)
+ .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)
+ )
+ }
+ }
+ }
+ }
+
+ @ViewBuilder private func conditionsDiffButton(_ font: Font? = nil) -> some View {
+ let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit
+ if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") {
+ Link(destination: commitUrl) {
+ HStack {
+ Text("Open changes")
+ Image(systemName: "arrow.up.right.circle")
+ }
+ .font(font)
+ }
+ }
+ }
+}
+
+private func regularConditionsHeader() -> some View {
+ HStack {
+ Text("Conditions of use").font(.largeTitle).bold()
+ Spacer()
+ conditionsLinkButton()
+ }
+}
+
+struct SimpleConditionsView: View {
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ regularConditionsHeader()
+ .padding(.top)
+ .padding(.top)
+ ConditionsTextView()
+ .padding(.bottom)
+ .padding(.bottom)
+ }
+ .padding(.horizontal, 25)
+ .frame(maxHeight: .infinity)
+ }
+}
+
+func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) {
+ let userServersToValidate = userServers.wrappedValue
+ Task {
+ do {
+ let errs = try await validateServers(userServers: userServersToValidate)
+ await MainActor.run {
+ serverErrors.wrappedValue = errs
+ }
+ } catch let error {
+ logger.error("validateServers error: \(responseError(error))")
+ }
+ }
+}
+
+func serversCanBeSaved(
+ _ currUserServers: [UserOperatorServers],
+ _ userServers: [UserOperatorServers],
+ _ serverErrors: [UserServersError]
+) -> Bool {
+ return userServers != currUserServers && serverErrors.isEmpty
+}
+
+struct ServersErrorView: View {
+ @EnvironmentObject var theme: AppTheme
+ var errStr: String
+
+ var body: some View {
+ HStack {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundColor(.red)
+ Text(errStr)
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+}
+
+func globalServersError(_ serverErrors: [UserServersError]) -> String? {
+ for err in serverErrors {
+ if let errStr = err.globalError {
+ return errStr
+ }
+ }
+ return nil
+}
+
+func globalSMPServersError(_ serverErrors: [UserServersError]) -> String? {
+ for err in serverErrors {
+ if let errStr = err.globalSMPError {
+ return errStr
+ }
+ }
+ return nil
+}
+
+func globalXFTPServersError(_ serverErrors: [UserServersError]) -> String? {
+ for err in serverErrors {
+ if let errStr = err.globalXFTPError {
+ return errStr
+ }
+ }
+ return nil
+}
+
+func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set {
+ let duplicateHostsList = serverErrors.compactMap { err in
+ if case let .duplicateServer(_, _, duplicateHost) = err {
+ return duplicateHost
+ } else {
+ return nil
+ }
+ }
+ return Set(duplicateHostsList)
+}
+
+func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServers: Binding<[UserOperatorServers]>) {
+ let userServersToSave = userServers.wrappedValue
+ Task {
+ do {
+ try await setUserServers(userServers: userServersToSave)
+ // Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers)
+ do {
+ let updatedServers = try await getUserServers()
+ let updatedOperators = try await getServerOperators()
+ await MainActor.run {
+ ChatModel.shared.conditions = updatedOperators
+ currUserServers.wrappedValue = updatedServers
+ userServers.wrappedValue = updatedServers
+ }
+ } catch let error {
+ logger.error("saveServers getUserServers error: \(responseError(error))")
+ await MainActor.run {
+ currUserServers.wrappedValue = userServersToSave
+ }
+ }
+ } catch let error {
+ logger.error("saveServers setUserServers error: \(responseError(error))")
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error saving servers", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
+ }
+ }
+}
+
+func updateOperatorsConditionsAcceptance(_ usvs: Binding<[UserOperatorServers]>, _ updatedOperators: [ServerOperator]) {
+ for i in 0.. some View {
+ VStack {
+ let serverAddress = parseServerAddress(serverToEdit.server)
+ let valid = serverAddress?.valid == true
+ List {
+ Section {
+ TextEditor(text: $serverToEdit.server)
+ .multilineTextAlignment(.leading)
+ .autocorrectionDisabled(true)
+ .autocapitalization(.none)
+ .allowsTightening(true)
+ .lineLimit(10)
+ .frame(height: 144)
+ .padding(-6)
+ } header: {
+ HStack {
+ Text("Your server address")
+ .foregroundColor(theme.colors.secondary)
+ if !valid {
+ Spacer()
+ Image(systemName: "exclamationmark.circle").foregroundColor(.red)
+ }
+ }
+ }
+ useServerSection(valid)
+ if valid {
+ Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) {
+ MutableQRCode(uri: $serverToEdit.server)
+ .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
+ }
+ }
+ }
+ }
+ }
+
+ private func useServerSection(_ valid: Bool) -> some View {
+ Section(header: Text("Use server").foregroundColor(theme.colors.secondary)) {
+ HStack {
+ Button("Test server") {
+ testing = true
+ serverToEdit.tested = nil
+ Task {
+ if let f = await testServerConnection(server: $serverToEdit) {
+ showTestFailure = true
+ testFailure = f
+ }
+ await MainActor.run { testing = false }
+ }
+ }
+ .disabled(!valid || testing)
+ Spacer()
+ showTestStatus(server: serverToEdit)
+ }
+ Toggle("Use for new connections", isOn: $serverToEdit.enabled)
+ }
+ }
+}
+
+func serverProtocolAndOperator(_ server: UserServer, _ userServers: [UserOperatorServers]) -> (ServerProtocol, ServerOperator?)? {
+ if let serverAddress = parseServerAddress(server.server) {
+ let serverProtocol = serverAddress.serverProtocol
+ let hostnames = serverAddress.hostnames
+ let matchingOperator = userServers.compactMap { $0.operator }.first { op in
+ op.serverDomains.contains { domain in
+ hostnames.contains { hostname in
+ hostname.hasSuffix(domain)
+ }
+ }
+ }
+ return (serverProtocol, matchingOperator)
+ } else {
+ return nil
+ }
+}
+
+func addServer(
+ _ server: UserServer,
+ _ userServers: Binding<[UserOperatorServers]>,
+ _ serverErrors: Binding<[UserServersError]>,
+ _ dismiss: DismissAction
+) {
+ if let (serverProtocol, matchingOperator) = serverProtocolAndOperator(server, userServers.wrappedValue) {
+ if let i = userServers.wrappedValue.firstIndex(where: { $0.operator?.operatorId == matchingOperator?.operatorId }) {
+ switch serverProtocol {
+ case .smp: userServers[i].wrappedValue.smpServers.append(server)
+ case .xftp: userServers[i].wrappedValue.xftpServers.append(server)
+ }
+ validateServers_(userServers, serverErrors)
+ dismiss()
+ if let op = matchingOperator {
+ showAlert(
+ NSLocalizedString("Operator server", comment: "alert title"),
+ message: String.localizedStringWithFormat(NSLocalizedString("Server added to operator %@.", comment: "alert message"), op.tradeName)
+ )
+ }
+ } else { // Shouldn't happen
+ dismiss()
+ showAlert(NSLocalizedString("Error adding server", comment: "alert title"))
+ }
+ } else {
+ dismiss()
+ if server.server.trimmingCharacters(in: .whitespaces) != "" {
+ showAlert(
+ NSLocalizedString("Invalid server address!", comment: "alert title"),
+ message: NSLocalizedString("Check server address and try again.", comment: "alert title")
+ )
+ }
+ }
+}
+
+#Preview {
+ NewServerView(
+ userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
+ serverErrors: Binding.constant([])
+ )
+}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift
new file mode 100644
index 0000000000..afbccc109c
--- /dev/null
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift
@@ -0,0 +1,586 @@
+//
+// OperatorView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 28.10.2024.
+// Copyright © 2024 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+import Ink
+
+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)
+ }
+
+ private func operatorView() -> some View {
+ let duplicateHosts = findDuplicateHosts(serverErrors)
+ return VStack {
+ List {
+ Section {
+ infoViewLink()
+ useOperatorToggle()
+ } header: {
+ Text("Operator")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else {
+ switch (userServers[operatorIndex].operator_.conditionsAcceptance) {
+ case let .accepted(acceptedAt, _):
+ if let acceptedAt = acceptedAt {
+ Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).")
+ .foregroundColor(theme.colors.secondary)
+ }
+ case let .required(deadline):
+ if userServers[operatorIndex].operator_.enabled, let deadline = deadline {
+ Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+ }
+ }
+
+ if userServers[operatorIndex].operator_.enabled {
+ if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
+ Section {
+ Toggle("To receive", isOn: $userServers[operatorIndex].operator_.smpRoles.storage)
+ .onChange(of: userServers[operatorIndex].operator_.smpRoles.storage) { _ in
+ validateServers_($userServers, $serverErrors)
+ }
+ Toggle("For private routing", isOn: $userServers[operatorIndex].operator_.smpRoles.proxy)
+ .onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Use for messages")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalSMPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ }
+ }
+ }
+
+ // Preset servers can't be deleted
+ if !userServers[operatorIndex].smpServers.filter({ $0.preset }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].smpServers) { srv in
+ if srv.wrappedValue.preset {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .smp,
+ backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ } header: {
+ Text("Message servers")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalSMPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else {
+ Text("The servers for new connections of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(10)
+ }
+ }
+ }
+
+ if !userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].smpServers) { srv in
+ if !srv.wrappedValue.preset && !srv.wrappedValue.deleted {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .smp,
+ backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ .onDelete { indexSet in
+ deleteSMPServer($userServers, operatorIndex, indexSet)
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Added message servers")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+
+ if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty {
+ Section {
+ Toggle("To send", isOn: $userServers[operatorIndex].operator_.xftpRoles.storage)
+ .onChange(of: userServers[operatorIndex].operator_.xftpRoles.storage) { _ in
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Use for files")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalXFTPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ }
+ }
+ }
+
+ // Preset servers can't be deleted
+ if !userServers[operatorIndex].xftpServers.filter({ $0.preset }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].xftpServers) { srv in
+ if srv.wrappedValue.preset {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .xftp,
+ backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ } header: {
+ Text("Media & file servers")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalXFTPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else {
+ Text("The servers for new files of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(10)
+ }
+ }
+ }
+
+ if !userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].xftpServers) { srv in
+ if !srv.wrappedValue.preset && !srv.wrappedValue.deleted {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .xftp,
+ backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ .onDelete { indexSet in
+ deleteXFTPServer($userServers, operatorIndex, indexSet)
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Added media & file servers")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+
+ Section {
+ TestServersButton(
+ smpServers: $userServers[operatorIndex].smpServers,
+ xftpServers: $userServers[operatorIndex].xftpServers,
+ testing: $testing
+ )
+ }
+ }
+ }
+ }
+ .toolbar {
+ if (
+ !userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty ||
+ !userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty
+ ) {
+ EditButton()
+ }
+ }
+ .sheet(isPresented: $showConditionsSheet, onDismiss: onUseToggleSheetDismissed) {
+ SingleOperatorUsageConditionsView(
+ currUserServers: $currUserServers,
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ operatorIndex: operatorIndex
+ )
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+
+ private func infoViewLink() -> some View {
+ NavigationLink() {
+ OperatorInfoView(serverOperator: userServers[operatorIndex].operator_)
+ .navigationBarTitle("Network operator")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ Image(userServers[operatorIndex].operator_.largeLogo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .grayscale(userServers[operatorIndex].operator_.enabled ? 0.0 : 1.0)
+ .frame(height: 40)
+ }
+ }
+
+ private func useOperatorToggle() -> some View {
+ Toggle("Use servers", isOn: $useOperator)
+ .onChange(of: useOperator) { useOperatorToggle in
+ if useOperatorToggleReset {
+ useOperatorToggleReset = false
+ } else if useOperatorToggle {
+ switch userServers[operatorIndex].operator_.conditionsAcceptance {
+ case .accepted:
+ userServers[operatorIndex].operator_.enabled = true
+ validateServers_($userServers, $serverErrors)
+ case let .required(deadline):
+ if deadline == nil {
+ showConditionsSheet = true
+ } else {
+ userServers[operatorIndex].operator_.enabled = true
+ validateServers_($userServers, $serverErrors)
+ }
+ }
+ } else {
+ userServers[operatorIndex].operator_.enabled = false
+ validateServers_($userServers, $serverErrors)
+ }
+ }
+ }
+
+ private func onUseToggleSheetDismissed() {
+ if useOperator && !userServers[operatorIndex].operator_.conditionsAcceptance.usageAllowed {
+ useOperatorToggleReset = true
+ useOperator = false
+ }
+ }
+}
+
+func conditionsTimestamp(_ date: Date) -> String {
+ let localDateFormatter = DateFormatter()
+ localDateFormatter.dateStyle = .medium
+ localDateFormatter.timeStyle = .none
+ return localDateFormatter.string(from: date)
+}
+
+struct OperatorInfoView: View {
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.colorScheme) var colorScheme: ColorScheme
+ var serverOperator: ServerOperator
+
+ var body: some View {
+ VStack {
+ List {
+ Section {
+ VStack(alignment: .leading) {
+ Image(serverOperator.largeLogo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .frame(height: 48)
+ if let legalName = serverOperator.legalName {
+ Text(legalName)
+ }
+ }
+ }
+ Section {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(serverOperator.info.description, id: \.self) { d in
+ Text(d)
+ }
+ }
+ Link(serverOperator.info.website.absoluteString, destination: serverOperator.info.website)
+ }
+ if let selfhost = serverOperator.info.selfhost {
+ Section {
+ Link(selfhost.text, destination: selfhost.link)
+ }
+ }
+ }
+ }
+ }
+}
+
+struct ConditionsTextView: View {
+ @State private var conditionsData: (UsageConditions, String?, UsageConditions?)?
+ @State private var failedToLoad: Bool = false
+ @State private var conditionsHTML: String? = nil
+
+ 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 {
+ let conditions = try await getUsageConditions()
+ let conditionsText = conditions.1
+ let parentLink = "https://github.com/simplex-chat/simplex-chat/blob/\(conditions.0.conditionsCommit)"
+ let preparedText: String?
+ if let conditionsText {
+ let prepared = prepareMarkdown(conditionsText.trimmingCharacters(in: .whitespacesAndNewlines), parentLink)
+ conditionsHTML = MarkdownParser().html(from: prepared)
+ preparedText = prepared
+ } else {
+ preparedText = nil
+ }
+ conditionsData = (conditions.0, preparedText, conditions.2)
+ } catch let error {
+ logger.error("ConditionsTextView getUsageConditions error: \(responseError(error))")
+ failedToLoad = true
+ }
+ }
+ }
+
+ // TODO Diff rendering
+ @ViewBuilder private func viewBody() -> some View {
+ if let (usageConditions, _, _) = conditionsData {
+ if let conditionsHTML {
+ ConditionsWebView(html: conditionsHTML)
+ .padding(6)
+ .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)
+ }
+ }
+ }
+
+ private func prepareMarkdown(_ text: String, _ parentLink: String) -> String {
+ let localLinkRegex = try! NSRegularExpression(pattern: "\\[([^\\(]*)\\]\\(#.*\\)")
+ let h1Regex = try! NSRegularExpression(pattern: "^# ")
+ var text = localLinkRegex.stringByReplacingMatches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count), withTemplate: "$1")
+ text = h1Regex.stringByReplacingMatches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count), withTemplate: "")
+ return text
+ .replacingOccurrences(of: "](/", with: "](\(parentLink)/")
+ .replacingOccurrences(of: "](./", with: "](\(parentLink)/")
+ }
+}
+
+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
+
+ 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) {
+ viewHeader()
+ ConditionsTextView()
+ }
+ .padding(.bottom)
+ .padding(.bottom)
+ .padding(.horizontal)
+ .frame(maxHeight: .infinity)
+
+ } else if !operatorsWithConditionsAccepted.isEmpty {
+
+ NavigationView {
+ VStack(alignment: .leading, spacing: 20) {
+ viewHeader()
+ Text("Conditions are already accepted for these operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
+ Text("The same conditions will apply to operator **\(userServers[operatorIndex].operator_.legalName_)**.")
+ conditionsAppliedToOtherOperatorsText()
+ Spacer()
+
+ acceptConditionsButton()
+ usageConditionsNavLinkButton()
+ }
+ .padding(.bottom)
+ .padding(.bottom)
+ .padding(.horizontal)
+ .frame(maxHeight: .infinity)
+ }
+
+ } else {
+
+ VStack(alignment: .leading, spacing: 20) {
+ viewHeader()
+ Text("To use the servers of **\(userServers[operatorIndex].operator_.legalName_)**, accept conditions of use.")
+ conditionsAppliedToOtherOperatorsText()
+ ConditionsTextView()
+ acceptConditionsButton()
+ .padding(.bottom)
+ .padding(.bottom)
+ }
+ .padding(.horizontal)
+ .frame(maxHeight: .infinity)
+
+ }
+ }
+
+ private func viewHeader() -> some View {
+ HStack {
+ Text("Use \(userServers[operatorIndex].operator_.tradeName)").font(.largeTitle).bold()
+ Spacer()
+ conditionsLinkButton()
+ }
+ .padding(.top)
+ .padding(.top)
+ }
+
+ @ViewBuilder private func conditionsAppliedToOtherOperatorsText() -> some View {
+ let otherOperatorsToApply = ChatModel.shared.conditions.serverOperators.filter {
+ $0.enabled &&
+ !$0.conditionsAcceptance.conditionsAccepted &&
+ $0.operatorId != userServers[operatorIndex].operator_.operatorId
+ }
+ if !otherOperatorsToApply.isEmpty {
+ Text("These conditions will also apply for: **\(otherOperatorsToApply.map { $0.legalName_ }.joined(separator: ", "))**.")
+ }
+ }
+
+ 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 }
+ return 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 {
+ NavigationLink("View conditions") {
+ ConditionsTextView()
+ .padding()
+ .navigationTitle("Conditions of use")
+ .navigationBarTitleDisplayMode(.large)
+ .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
+ .modifier(ThemedBackground(grouped: true))
+ }
+ .font(.callout)
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+}
+
+func conditionsLinkButton() -> some View {
+ let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit
+ let mdUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/\(commit)/PRIVACY.md") ?? conditionsURL
+ return Menu {
+ Link(destination: mdUrl) {
+ Label("Open conditions", systemImage: "doc")
+ }
+ if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") {
+ Link(destination: commitUrl) {
+ Label("Open changes", systemImage: "ellipsis")
+ }
+ }
+ } label: {
+ Image(systemName: "arrow.up.right.circle")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20)
+ .padding(2)
+ .contentShape(Circle())
+ }
+}
+
+#Preview {
+ OperatorView(
+ currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
+ userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
+ serverErrors: Binding.constant([]),
+ operatorIndex: 1,
+ useOperator: ServerOperator.sampleData1.enabled
+ )
+}
diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift
similarity index 70%
rename from apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift
rename to apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift
index da29dfac29..13d01874ed 100644
--- a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift
@@ -12,15 +12,15 @@ import SimpleXChat
struct ProtocolServerView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
- let serverProtocol: ServerProtocol
- @Binding var server: ServerCfg
- @State var serverToEdit: ServerCfg
+ @Binding var userServers: [UserOperatorServers]
+ @Binding var serverErrors: [UserServersError]
+ @Binding var server: UserServer
+ @State var serverToEdit: UserServer
+ var backLabel: LocalizedStringKey
@State private var showTestFailure = false
@State private var testing = false
@State private var testFailure: ProtocolTestFailure?
- var proto: String { serverProtocol.rawValue.uppercased() }
-
var body: some View {
ZStack {
if server.preset {
@@ -32,9 +32,33 @@ struct ProtocolServerView: View {
ProgressView().scaleEffect(2)
}
}
- .modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) {
- server = serverToEdit
- dismiss()
+ .modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) {
+ if let (serverToEditProtocol, serverToEditOperator) = serverProtocolAndOperator(serverToEdit, userServers),
+ let (serverProtocol, serverOperator) = serverProtocolAndOperator(server, userServers) {
+ if serverToEditProtocol != serverProtocol {
+ dismiss()
+ showAlert(
+ NSLocalizedString("Error updating server", comment: "alert title"),
+ message: NSLocalizedString("Server protocol changed.", comment: "alert title")
+ )
+ } else if serverToEditOperator != serverOperator {
+ dismiss()
+ showAlert(
+ NSLocalizedString("Error updating server", comment: "alert title"),
+ message: NSLocalizedString("Server operator changed.", comment: "alert title")
+ )
+ } else {
+ server = serverToEdit
+ validateServers_($userServers, $serverErrors)
+ dismiss()
+ }
+ } else {
+ dismiss()
+ showAlert(
+ NSLocalizedString("Invalid server address!", comment: "alert title"),
+ message: NSLocalizedString("Check server address and try again.", comment: "alert title")
+ )
+ }
})
.alert(isPresented: $showTestFailure) {
Alert(
@@ -62,7 +86,7 @@ struct ProtocolServerView: View {
private func customServer() -> some View {
VStack {
let serverAddress = parseServerAddress(serverToEdit.server)
- let valid = serverAddress?.valid == true && serverAddress?.serverProtocol == serverProtocol
+ let valid = serverAddress?.valid == true
List {
Section {
TextEditor(text: $serverToEdit.server)
@@ -112,10 +136,7 @@ struct ProtocolServerView: View {
Spacer()
showTestStatus(server: serverToEdit)
}
- let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
- .disabled(useForNewDisabled)
- .foregroundColor(useForNewDisabled ? theme.colors.secondary : theme.colors.onBackground)
}
}
}
@@ -142,7 +163,7 @@ struct BackButton: ViewModifier {
}
}
-@ViewBuilder func showTestStatus(server: ServerCfg) -> some View {
+@ViewBuilder func showTestStatus(server: UserServer) -> some View {
switch server.tested {
case .some(true):
Image(systemName: "checkmark")
@@ -155,7 +176,7 @@ struct BackButton: ViewModifier {
}
}
-func testServerConnection(server: Binding) async -> ProtocolTestFailure? {
+func testServerConnection(server: Binding) async -> ProtocolTestFailure? {
do {
let r = try await testProtoServer(server: server.wrappedValue.server)
switch r {
@@ -178,9 +199,11 @@ func testServerConnection(server: Binding) async -> ProtocolTestFailu
struct ProtocolServerView_Previews: PreviewProvider {
static var previews: some View {
ProtocolServerView(
- serverProtocol: .smp,
- server: Binding.constant(ServerCfg.sampleData.custom),
- serverToEdit: ServerCfg.sampleData.custom
+ userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
+ serverErrors: Binding.constant([]),
+ server: Binding.constant(UserServer.sampleData.custom),
+ serverToEdit: UserServer.sampleData.custom,
+ backLabel: "Your SMP servers"
)
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift
new file mode 100644
index 0000000000..b9737914ec
--- /dev/null
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift
@@ -0,0 +1,359 @@
+//
+// ProtocolServersView.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 15/11/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")!
+
+struct YourServersView: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject private var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.editMode) private var editMode
+ @Binding var userServers: [UserOperatorServers]
+ @Binding var serverErrors: [UserServersError]
+ var operatorIndex: Int
+ @State private var selectedServer: String? = nil
+ @State private var showAddServer = false
+ @State private var newServerNavLinkActive = false
+ @State private var showScanProtoServer = false
+ @State private var testing = false
+
+ var body: some View {
+ yourServersView()
+ .opacity(testing ? 0.4 : 1)
+ .overlay {
+ if testing {
+ ProgressView()
+ .scaleEffect(2)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .allowsHitTesting(!testing)
+ }
+
+ private func yourServersView() -> some View {
+ let duplicateHosts = findDuplicateHosts(serverErrors)
+ return List {
+ if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].smpServers) { srv in
+ if !srv.wrappedValue.deleted {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .smp,
+ backLabel: "Your servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ .onDelete { indexSet in
+ deleteSMPServer($userServers, operatorIndex, indexSet)
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Message servers")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalSMPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else {
+ Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(10)
+ }
+ }
+ }
+
+ if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty {
+ Section {
+ ForEach($userServers[operatorIndex].xftpServers) { srv in
+ if !srv.wrappedValue.deleted {
+ ProtocolServerViewLink(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ duplicateHosts: duplicateHosts,
+ server: srv,
+ serverProtocol: .xftp,
+ backLabel: "Your servers",
+ selectedServer: $selectedServer
+ )
+ } else {
+ EmptyView()
+ }
+ }
+ .onDelete { indexSet in
+ deleteXFTPServer($userServers, operatorIndex, indexSet)
+ validateServers_($userServers, $serverErrors)
+ }
+ } header: {
+ Text("Media & file servers")
+ .foregroundColor(theme.colors.secondary)
+ } footer: {
+ if let errStr = globalXFTPServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ } else {
+ Text("The servers for new files of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(10)
+ }
+ }
+ }
+
+ Section {
+ ZStack {
+ Button("Add server") {
+ showAddServer = true
+ }
+
+ NavigationLink(isActive: $newServerNavLinkActive) {
+ newServerDestinationView()
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+ }
+ } footer: {
+ if let errStr = globalServersError(serverErrors) {
+ ServersErrorView(errStr: errStr)
+ }
+ }
+
+ Section {
+ TestServersButton(
+ smpServers: $userServers[operatorIndex].smpServers,
+ xftpServers: $userServers[operatorIndex].xftpServers,
+ testing: $testing
+ )
+ howToButton()
+ }
+ }
+ .toolbar {
+ if (
+ !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty ||
+ !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty
+ ) {
+ EditButton()
+ }
+ }
+ .confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
+ Button("Enter server manually") { newServerNavLinkActive = true }
+ Button("Scan server QR code") { showScanProtoServer = true }
+ }
+ .sheet(isPresented: $showScanProtoServer) {
+ ScanProtocolServer(
+ userServers: $userServers,
+ serverErrors: $serverErrors
+ )
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+
+ private func newServerDestinationView() -> some View {
+ NewServerView(
+ userServers: $userServers,
+ serverErrors: $serverErrors
+ )
+ .navigationTitle("New server")
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ }
+
+ func howToButton() -> some View {
+ Button {
+ DispatchQueue.main.async {
+ UIApplication.shared.open(howToUrl)
+ }
+ } label: {
+ HStack {
+ Text("How to use your servers")
+ Image(systemName: "arrow.up.right.circle")
+ }
+ }
+ }
+}
+
+struct ProtocolServerViewLink: View {
+ @EnvironmentObject var theme: AppTheme
+ @Binding var userServers: [UserOperatorServers]
+ @Binding var serverErrors: [UserServersError]
+ var duplicateHosts: Set
+ @Binding var server: UserServer
+ var serverProtocol: ServerProtocol
+ var backLabel: LocalizedStringKey
+ @Binding var selectedServer: String?
+
+ var body: some View {
+ let proto = serverProtocol.rawValue.uppercased()
+
+ NavigationLink(tag: server.id, selection: $selectedServer) {
+ ProtocolServerView(
+ userServers: $userServers,
+ serverErrors: $serverErrors,
+ server: $server,
+ serverToEdit: server,
+ backLabel: backLabel
+ )
+ .navigationBarTitle("\(proto) server")
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ let address = parseServerAddress(server.server)
+ HStack {
+ Group {
+ if let address = address {
+ if !address.valid || address.serverProtocol != serverProtocol {
+ invalidServer()
+ } else if address.hostnames.contains(where: duplicateHosts.contains) {
+ Image(systemName: "exclamationmark.circle").foregroundColor(.red)
+ } else if !server.enabled {
+ Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
+ } else {
+ showTestStatus(server: server)
+ }
+ } else {
+ invalidServer()
+ }
+ }
+ .frame(width: 16, alignment: .center)
+ .padding(.trailing, 4)
+
+ let v = Text(address?.hostnames.first ?? server.server).lineLimit(1)
+ if server.enabled {
+ v
+ } else {
+ v.foregroundColor(theme.colors.secondary)
+ }
+ }
+ }
+ }
+
+ private func invalidServer() -> some View {
+ Image(systemName: "exclamationmark.circle").foregroundColor(.red)
+ }
+}
+
+func deleteSMPServer(
+ _ userServers: Binding<[UserOperatorServers]>,
+ _ operatorServersIndex: Int,
+ _ serverIndexSet: IndexSet
+) {
+ if let idx = serverIndexSet.first {
+ let server = userServers[operatorServersIndex].wrappedValue.smpServers[idx]
+ if server.serverId == nil {
+ userServers[operatorServersIndex].wrappedValue.smpServers.remove(at: idx)
+ } else {
+ var updatedServer = server
+ updatedServer.deleted = true
+ userServers[operatorServersIndex].wrappedValue.smpServers[idx] = updatedServer
+ }
+ }
+}
+
+func deleteXFTPServer(
+ _ userServers: Binding<[UserOperatorServers]>,
+ _ operatorServersIndex: Int,
+ _ serverIndexSet: IndexSet
+) {
+ if let idx = serverIndexSet.first {
+ let server = userServers[operatorServersIndex].wrappedValue.xftpServers[idx]
+ if server.serverId == nil {
+ userServers[operatorServersIndex].wrappedValue.xftpServers.remove(at: idx)
+ } else {
+ var updatedServer = server
+ updatedServer.deleted = true
+ userServers[operatorServersIndex].wrappedValue.xftpServers[idx] = updatedServer
+ }
+ }
+}
+
+struct TestServersButton: View {
+ @Binding var smpServers: [UserServer]
+ @Binding var xftpServers: [UserServer]
+ @Binding var testing: Bool
+
+ var body: some View {
+ Button("Test servers", action: testServers)
+ .disabled(testing || allServersDisabled)
+ }
+
+ private var allServersDisabled: Bool {
+ smpServers.allSatisfy { !$0.enabled } && xftpServers.allSatisfy { !$0.enabled }
+ }
+
+ private func testServers() {
+ resetTestStatus()
+ testing = true
+ Task {
+ let fs = await runServersTest()
+ await MainActor.run {
+ testing = false
+ if !fs.isEmpty {
+ let msg = fs.map { (srv, f) in
+ "\(srv): \(f.localizedDescription)"
+ }.joined(separator: "\n")
+ showAlert(
+ NSLocalizedString("Tests failed!", comment: "alert title"),
+ message: String.localizedStringWithFormat(NSLocalizedString("Some servers failed the test:\n%@", comment: "alert message"), msg)
+ )
+ }
+ }
+ }
+ }
+
+ private func resetTestStatus() {
+ for i in 0.. [String: ProtocolTestFailure] {
+ var fs: [String: ProtocolTestFailure] = [:]
+ for i in 0..) {
switch resp {
case let .success(r):
- if parseServerAddress(r.string) != nil {
- servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false))
- dismiss()
- } else {
- showAddressError = true
- }
+ var server: UserServer = .empty
+ server.server = r.string
+ addServer(server, $userServers, $serverErrors, dismiss)
case let .failure(e):
logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)")
dismiss()
@@ -54,6 +45,9 @@ struct ScanProtocolServer: View {
struct ScanProtocolServer_Previews: PreviewProvider {
static var previews: some View {
- ScanProtocolServer(servers: Binding.constant([]))
+ ScanProtocolServer(
+ userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
+ serverErrors: Binding.constant([])
+ )
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift
index b9c92c9919..c4d0588987 100644
--- a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift
@@ -13,7 +13,7 @@ struct NotificationsView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@State private var notificationMode: NotificationsMode = ChatModel.shared.notificationMode
- @State private var showAlert: NotificationAlert?
+ @State private var ntfAlert: NotificationAlert?
@State private var legacyDatabase = dbContainerGroupDefault.get() == .documents
@State private var testing = false
@State private var testedSuccess: Bool? = nil
@@ -25,7 +25,7 @@ struct NotificationsView: View {
ProgressView().scaleEffect(2)
}
}
- .alert(item: $showAlert) { alert in
+ .alert(item: $ntfAlert) { alert in
if let token = m.deviceToken {
return notificationAlert(alert, token)
} else {
@@ -41,7 +41,7 @@ struct NotificationsView: View {
List {
Section {
SelectionListView(list: NotificationsMode.values, selection: $notificationMode) { mode in
- showAlert = .setMode(mode: mode)
+ ntfAlert = .setMode(mode: mode)
}
} footer: {
VStack(alignment: .leading) {
@@ -95,7 +95,7 @@ struct NotificationsView: View {
if let server = m.notificationServer {
smpServers("Push server", [server], theme.colors.secondary)
- testServerButton(server)
+ testTokenButton(server)
}
} header: {
Text("Push notifications")
@@ -163,7 +163,7 @@ struct NotificationsView: View {
await MainActor.run {
let err = responseError(error)
logger.error("apiDeleteToken error: \(err)")
- showAlert = .error(title: "Error deleting token", error: err)
+ ntfAlert = .error(title: "Error deleting token", error: err)
}
}
default:
@@ -181,19 +181,19 @@ struct NotificationsView: View {
await MainActor.run {
let err = responseError(error)
logger.error("apiRegisterToken error: \(err)")
- showAlert = .error(title: "Error enabling notifications", error: err)
+ ntfAlert = .error(title: "Error enabling notifications", error: err)
}
}
}
}
}
- private func testServerButton(_ server: String) -> some View {
+ private func testTokenButton(_ server: String) -> some View {
HStack {
- Button("Test server") {
+ Button("Test notifications") {
testing = true
Task {
- await testServer(server)
+ await testServerAndToken(server)
await MainActor.run { testing = false }
}
}
@@ -215,31 +215,78 @@ struct NotificationsView: View {
}
}
- private func testServer(_ server: String) async {
+ private func testServerAndToken(_ server: String) async {
do {
let r = try await testProtoServer(server: server)
switch r {
case .success:
- await MainActor.run {
- testedSuccess = true
+ if let token = m.deviceToken {
+ do {
+ let status = try await apiCheckToken(token: token)
+ await MainActor.run {
+ m.tokenStatus = status
+ testedSuccess = status.workingToken
+ if status.workingToken {
+ showAlert(
+ NSLocalizedString("Notifications status", comment: "alert title"),
+ message: tokenStatusInfo(status, register: false)
+ )
+ } else {
+ showAlert(
+ title: NSLocalizedString("Notifications error", comment: "alert title"),
+ message: tokenStatusInfo(status, register: true),
+ buttonTitle: "Register",
+ buttonAction: {
+ reRegisterToken(token: token)
+ testedSuccess = nil
+ },
+ cancelButton: true
+ )
+ }
+ }
+ } catch let error {
+ await MainActor.run {
+ let err = responseError(error)
+ logger.error("apiCheckToken \(err)")
+ ntfAlert = .error(title: "Error checking token status", error: err)
+ }
+ }
+ } else {
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("No token!", comment: "alert title")
+ )
+ }
}
case let .failure(f):
await MainActor.run {
- showAlert = .testFailure(testFailure: f)
+ ntfAlert = .testFailure(testFailure: f)
testedSuccess = false
}
}
} catch let error {
- logger.error("testServerConnection \(responseError(error))")
+ await MainActor.run {
+ let err = responseError(error)
+ logger.error("testServerConnection \(err)")
+ ntfAlert = .error(title: "Error testing server connection", error: err)
+ }
}
}
}
func ntfModeDescription(_ mode: NotificationsMode) -> LocalizedStringKey {
switch mode {
- case .off: return "**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)."
- case .periodic: return "**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have."
- case .instant: return "**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from."
+ case .off: return "**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app."
+ case .periodic: return "**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata."
+ case .instant: return "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from."
+ }
+}
+
+func ntfModeShortDescription(_ mode: NotificationsMode) -> LocalizedStringKey {
+ switch mode {
+ case .off: return "Check messages when allowed."
+ case .periodic: return "Check messages every 20 min."
+ case .instant: return "E2E encrypted notifications."
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
index 62aad348a7..eba7f8066a 100644
--- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
@@ -19,6 +19,8 @@ struct PrivacySettings: View {
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
+ @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
+ @AppStorage(DEFAULT_PRIVACY_SHORT_LINKS) private var shortSimplexLinks = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@State private var currentLAMode = privacyLocalAuthModeDefault.get()
@@ -99,6 +101,11 @@ struct PrivacySettings: View {
.onChange(of: simplexLinkMode) { mode in
privacySimplexLinkModeDefault.set(mode)
}
+ if developerTools {
+ settingsRow("link.badge.plus", color: theme.colors.secondary) {
+ Toggle("Use short links (BETA)", isOn: $shortSimplexLinks)
+ }
+ }
} header: {
Text("Chats")
.foregroundColor(theme.colors.secondary)
diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift
deleted file mode 100644
index 0fb37d5c49..0000000000
--- a/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift
+++ /dev/null
@@ -1,359 +0,0 @@
-//
-// ProtocolServersView.swift
-// SimpleX (iOS)
-//
-// Created by Evgeny on 15/11/2022.
-// Copyright © 2022 SimpleX Chat. All rights reserved.
-//
-
-import SwiftUI
-import SimpleXChat
-
-private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")!
-
-struct ProtocolServersView: View {
- @Environment(\.dismiss) var dismiss: DismissAction
- @EnvironmentObject private var m: ChatModel
- @EnvironmentObject var theme: AppTheme
- @Environment(\.editMode) private var editMode
- let serverProtocol: ServerProtocol
- @State private var currServers: [ServerCfg] = []
- @State private var presetServers: [ServerCfg] = []
- @State private var configuredServers: [ServerCfg] = []
- @State private var otherServers: [ServerCfg] = []
- @State private var selectedServer: String? = nil
- @State private var showAddServer = false
- @State private var showScanProtoServer = false
- @State private var justOpened = true
- @State private var testing = false
- @State private var alert: ServerAlert? = nil
- @State private var showSaveDialog = false
-
- var proto: String { serverProtocol.rawValue.uppercased() }
-
- var body: some View {
- ZStack {
- protocolServersView()
- if testing {
- ProgressView().scaleEffect(2)
- }
- }
- }
-
- enum ServerAlert: Identifiable {
- case testsFailed(failures: [String: ProtocolTestFailure])
- case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
-
- var id: String {
- switch self {
- case .testsFailed: return "testsFailed"
- case let .error(title, _): return "error \(title)"
- }
- }
- }
-
- private func protocolServersView() -> some View {
- List {
- if !configuredServers.isEmpty {
- Section {
- ForEach($configuredServers) { srv in
- protocolServerView(srv)
- }
- .onMove { indexSet, offset in
- configuredServers.move(fromOffsets: indexSet, toOffset: offset)
- }
- .onDelete { indexSet in
- configuredServers.remove(atOffsets: indexSet)
- }
- } header: {
- Text("Configured \(proto) servers")
- .foregroundColor(theme.colors.secondary)
- } footer: {
- Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
- .foregroundColor(theme.colors.secondary)
- .lineLimit(10)
- }
- }
-
- if !otherServers.isEmpty {
- Section {
- ForEach($otherServers) { srv in
- protocolServerView(srv)
- }
- .onMove { indexSet, offset in
- otherServers.move(fromOffsets: indexSet, toOffset: offset)
- }
- .onDelete { indexSet in
- otherServers.remove(atOffsets: indexSet)
- }
- } header: {
- Text("Other \(proto) servers")
- .foregroundColor(theme.colors.secondary)
- }
- }
-
- Section {
- Button("Add server") {
- showAddServer = true
- }
- }
-
- Section {
- Button("Reset") { partitionServers(currServers) }
- .disabled(Set(allServers) == Set(currServers) || testing)
- Button("Test servers", action: testServers)
- .disabled(testing || allServersDisabled)
- Button("Save servers", action: saveServers)
- .disabled(saveDisabled)
- howToButton()
- }
- }
- .toolbar { EditButton() }
- .confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
- Button("Enter server manually") {
- otherServers.append(ServerCfg.empty)
- selectedServer = allServers.last?.id
- }
- Button("Scan server QR code") { showScanProtoServer = true }
- Button("Add preset servers", action: addAllPresets)
- .disabled(hasAllPresets())
- }
- .sheet(isPresented: $showScanProtoServer) {
- ScanProtocolServer(servers: $otherServers)
- .modifier(ThemedBackground(grouped: true))
- }
- .modifier(BackButton(disabled: Binding.constant(false)) {
- if saveDisabled {
- dismiss()
- justOpened = false
- } else {
- showSaveDialog = true
- }
- })
- .confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) {
- Button("Save") {
- saveServers()
- dismiss()
- justOpened = false
- }
- Button("Exit without saving") { dismiss() }
- }
- .alert(item: $alert) { a in
- switch a {
- case let .testsFailed(fs):
- let msg = fs.map { (srv, f) in
- "\(srv): \(f.localizedDescription)"
- }.joined(separator: "\n")
- return Alert(
- title: Text("Tests failed!"),
- message: Text("Some servers failed the test:\n" + msg)
- )
- case .error:
- return Alert(
- title: Text("Error")
- )
- }
- }
- .onAppear {
- // this condition is needed to prevent re-setting the servers when exiting single server view
- if justOpened {
- do {
- let r = try getUserProtoServers(serverProtocol)
- currServers = r.protoServers
- presetServers = r.presetServers
- partitionServers(currServers)
- } catch let error {
- alert = .error(
- title: "Error loading \(proto) servers",
- error: "Error: \(responseError(error))"
- )
- }
- justOpened = false
- } else {
- partitionServers(allServers)
- }
- }
- }
-
- private func partitionServers(_ servers: [ServerCfg]) {
- configuredServers = servers.filter { $0.preset || $0.enabled }
- otherServers = servers.filter { !($0.preset || $0.enabled) }
- }
-
- private var allServers: [ServerCfg] {
- configuredServers + otherServers
- }
-
- private var saveDisabled: Bool {
- allServers.isEmpty ||
- Set(allServers) == Set(currServers) ||
- testing ||
- !allServers.allSatisfy { srv in
- if let address = parseServerAddress(srv.server) {
- return uniqueAddress(srv, address)
- }
- return false
- } ||
- allServersDisabled
- }
-
- private var allServersDisabled: Bool {
- allServers.allSatisfy { !$0.enabled }
- }
-
- private func protocolServerView(_ server: Binding) -> some View {
- let srv = server.wrappedValue
- return NavigationLink(tag: srv.id, selection: $selectedServer) {
- ProtocolServerView(
- serverProtocol: serverProtocol,
- server: server,
- serverToEdit: srv
- )
- .navigationBarTitle(srv.preset ? "Preset server" : "Your server")
- .modifier(ThemedBackground(grouped: true))
- .navigationBarTitleDisplayMode(.large)
- } label: {
- let address = parseServerAddress(srv.server)
- HStack {
- Group {
- if let address = address {
- if !address.valid || address.serverProtocol != serverProtocol {
- invalidServer()
- } else if !uniqueAddress(srv, address) {
- Image(systemName: "exclamationmark.circle").foregroundColor(.red)
- } else if !srv.enabled {
- Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
- } else {
- showTestStatus(server: srv)
- }
- } else {
- invalidServer()
- }
- }
- .frame(width: 16, alignment: .center)
- .padding(.trailing, 4)
-
- let v = Text(address?.hostnames.first ?? srv.server).lineLimit(1)
- if srv.enabled {
- v
- } else {
- v.foregroundColor(theme.colors.secondary)
- }
- }
- }
- }
-
- func howToButton() -> some View {
- Button {
- DispatchQueue.main.async {
- UIApplication.shared.open(howToUrl)
- }
- } label: {
- HStack {
- Text("How to use your servers")
- Image(systemName: "arrow.up.right.circle")
- }
- }
- }
-
- private func invalidServer() -> some View {
- Image(systemName: "exclamationmark.circle").foregroundColor(.red)
- }
-
- private func uniqueAddress(_ s: ServerCfg, _ address: ServerAddress) -> Bool {
- allServers.allSatisfy { srv in
- address.hostnames.allSatisfy { host in
- srv.id == s.id || !srv.server.contains(host)
- }
- }
- }
-
- private func hasAllPresets() -> Bool {
- presetServers.allSatisfy { hasPreset($0) }
- }
-
- private func addAllPresets() {
- for srv in presetServers {
- if !hasPreset(srv) {
- configuredServers.append(srv)
- }
- }
- }
-
- private func hasPreset(_ srv: ServerCfg) -> Bool {
- allServers.contains(where: { $0.server == srv.server })
- }
-
- private func testServers() {
- resetTestStatus()
- testing = true
- Task {
- let fs = await runServersTest()
- await MainActor.run {
- testing = false
- if !fs.isEmpty {
- alert = .testsFailed(failures: fs)
- }
- }
- }
- }
-
- private func resetTestStatus() {
- for i in 0.. [String: ProtocolTestFailure] {
- var fs: [String: ProtocolTestFailure] = [:]
- for i in 0..(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME_IDS, withDefault: [:] )
@@ -272,154 +280,159 @@ struct SettingsView: View {
}
}
- @ViewBuilder func settingsView() -> some View {
- let user = chatModel.currentUser
- List {
- Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) {
- NavigationLink {
- NotificationsView()
- .navigationTitle("Notifications")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- HStack {
- notificationsIcon()
- Text("Notifications")
- }
- }
- .disabled(chatModel.chatRunning != true)
-
- NavigationLink {
- NetworkAndServers()
- .navigationTitle("Network & servers")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") }
- }
- .disabled(chatModel.chatRunning != true)
-
- NavigationLink {
- CallSettings()
- .navigationTitle("Your calls")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") }
- }
- .disabled(chatModel.chatRunning != true)
-
- NavigationLink {
- PrivacySettings()
- .navigationTitle("Your privacy")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- settingsRow("lock", color: theme.colors.secondary) { Text("Privacy & security") }
- }
- .disabled(chatModel.chatRunning != true)
-
- if UIApplication.shared.supportsAlternateIcons {
- NavigationLink {
- AppearanceSettings()
- .navigationTitle("Appearance")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- settingsRow("sun.max", color: theme.colors.secondary) { Text("Appearance") }
- }
- .disabled(chatModel.chatRunning != true)
+ func settingsView() -> some View {
+ List {
+ let user = chatModel.currentUser
+ Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) {
+ NavigationLink {
+ NotificationsView()
+ .navigationTitle("Notifications")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ HStack {
+ notificationsIcon()
+ Text("Notifications")
}
}
+ .disabled(chatModel.chatRunning != true)
- Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) {
- chatDatabaseRow()
- NavigationLink {
- MigrateFromDevice(showProgressOnSettings: $showProgress)
- .navigationTitle("Migrate device")
- .modifier(ThemedBackground(grouped: true))
- .navigationBarTitleDisplayMode(.large)
- } label: {
- settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
- }
+ NavigationLink {
+ NetworkAndServers()
+ .navigationTitle("Network & servers")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") }
}
-
- Section(header: Text("Help").foregroundColor(theme.colors.secondary)) {
- if let user = user {
- NavigationLink {
- ChatHelp(dismissSettingsSheet: dismiss)
- .navigationTitle("Welcome \(user.displayName)!")
- .modifier(ThemedBackground())
- .frame(maxHeight: .infinity, alignment: .top)
- } label: {
- settingsRow("questionmark", color: theme.colors.secondary) { Text("How to use it") }
- }
- }
+ .disabled(chatModel.chatRunning != true)
+
+ NavigationLink {
+ CallSettings()
+ .navigationTitle("Your calls")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") }
+ }
+ .disabled(chatModel.chatRunning != true)
+
+ NavigationLink {
+ PrivacySettings()
+ .navigationTitle("Your privacy")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ settingsRow("lock", color: theme.colors.secondary) { Text("Privacy & security") }
+ }
+ .disabled(chatModel.chatRunning != true)
+
+ if UIApplication.shared.supportsAlternateIcons {
NavigationLink {
- WhatsNewView(viaSettings: true)
- .modifier(ThemedBackground())
- .navigationBarTitleDisplayMode(.inline)
+ AppearanceSettings()
+ .navigationTitle("Appearance")
+ .modifier(ThemedBackground(grouped: true))
} label: {
- settingsRow("plus", color: theme.colors.secondary) { Text("What's new") }
+ settingsRow("sun.max", color: theme.colors.secondary) { Text("Appearance") }
}
+ .disabled(chatModel.chatRunning != true)
+ }
+ }
+
+ Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) {
+ chatDatabaseRow()
+ NavigationLink {
+ MigrateFromDevice(showProgressOnSettings: $showProgress)
+ .toolbar {
+ // Redaction broken for `.navigationTitle` - using a toolbar item instead.
+ ToolbarItem(placement: .principal) {
+ Text("Migrate device").font(.headline)
+ }
+ }
+ .modifier(ThemedBackground(grouped: true))
+ .navigationBarTitleDisplayMode(.large)
+ } label: {
+ settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
+ }
+ }
+
+ Section(header: Text("Help").foregroundColor(theme.colors.secondary)) {
+ if let user = user {
NavigationLink {
- SimpleXInfo(onboarding: false)
- .navigationBarTitle("", displayMode: .inline)
+ ChatHelp(dismissSettingsSheet: dismiss)
+ .navigationTitle("Welcome \(user.displayName)!")
.modifier(ThemedBackground())
.frame(maxHeight: .infinity, alignment: .top)
} label: {
- settingsRow("info", color: theme.colors.secondary) { Text("About SimpleX Chat") }
+ settingsRow("questionmark", color: theme.colors.secondary) { Text("How to use it") }
}
- settingsRow("number", color: theme.colors.secondary) {
- Button("Send questions and ideas") {
- dismiss()
- DispatchQueue.main.async {
- UIApplication.shared.open(simplexTeamURL)
- }
+ }
+ NavigationLink {
+ WhatsNewView(viaSettings: true, updatedConditions: false)
+ .modifier(ThemedBackground())
+ .navigationBarTitleDisplayMode(.inline)
+ } label: {
+ settingsRow("plus", color: theme.colors.secondary) { Text("What's new") }
+ }
+ NavigationLink {
+ SimpleXInfo(onboarding: false)
+ .navigationBarTitle("", displayMode: .inline)
+ .modifier(ThemedBackground())
+ .frame(maxHeight: .infinity, alignment: .top)
+ } label: {
+ settingsRow("info", color: theme.colors.secondary) { Text("About SimpleX Chat") }
+ }
+ settingsRow("number", color: theme.colors.secondary) {
+ Button("Send questions and ideas") {
+ dismiss()
+ DispatchQueue.main.async {
+ UIApplication.shared.open(simplexTeamURL)
}
}
- .disabled(chatModel.chatRunning != true)
- settingsRow("envelope", color: theme.colors.secondary) { Text("[Send us email](mailto:chat@simplex.chat)") }
}
+ .disabled(chatModel.chatRunning != true)
+ settingsRow("envelope", color: theme.colors.secondary) { Text("[Send us email](mailto:chat@simplex.chat)") }
+ }
- Section(header: Text("Support SimpleX Chat").foregroundColor(theme.colors.secondary)) {
- settingsRow("keyboard", color: theme.colors.secondary) { Text("[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)") }
- settingsRow("star", color: theme.colors.secondary) {
- Button("Rate the app") {
- if let scene = sceneDelegate.windowScene {
- SKStoreReviewController.requestReview(in: scene)
- }
+ Section(header: Text("Support SimpleX Chat").foregroundColor(theme.colors.secondary)) {
+ settingsRow("keyboard", color: theme.colors.secondary) { Text("[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)") }
+ settingsRow("star", color: theme.colors.secondary) {
+ Button("Rate the app") {
+ if let scene = sceneDelegate.windowScene {
+ SKStoreReviewController.requestReview(in: scene)
}
}
- ZStack(alignment: .leading) {
- Image(colorScheme == .dark ? "github_light" : "github")
- .resizable()
- .frame(width: 24, height: 24)
- .opacity(0.5)
- .colorMultiply(theme.colors.secondary)
- Text("[Star on GitHub](https://github.com/simplex-chat/simplex-chat)")
- .padding(.leading, indent)
- }
}
+ ZStack(alignment: .leading) {
+ Image(colorScheme == .dark ? "github_light" : "github")
+ .resizable()
+ .frame(width: 24, height: 24)
+ .opacity(0.5)
+ .colorMultiply(theme.colors.secondary)
+ Text("[Star on GitHub](https://github.com/simplex-chat/simplex-chat)")
+ .padding(.leading, indent)
+ }
+ }
- Section(header: Text("Develop").foregroundColor(theme.colors.secondary)) {
- NavigationLink {
- DeveloperView()
- .navigationTitle("Developer tools")
- .modifier(ThemedBackground(grouped: true))
- } label: {
- settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) { Text("Developer tools") }
- }
- NavigationLink {
- VersionView()
- .navigationBarTitle("App version")
- .modifier(ThemedBackground())
- } label: {
- Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
- }
+ Section(header: Text("Develop").foregroundColor(theme.colors.secondary)) {
+ NavigationLink {
+ DeveloperView()
+ .navigationTitle("Developer tools")
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) { Text("Developer tools") }
+ }
+ NavigationLink {
+ VersionView()
+ .navigationBarTitle("App version")
+ .modifier(ThemedBackground())
+ } label: {
+ Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
}
}
- .navigationTitle("Your settings")
- .modifier(ThemedBackground(grouped: true))
- .onDisappear {
- chatModel.showingTerminal = false
- chatModel.terminalItems = []
- }
+ }
+ .navigationTitle("Your settings")
+ .modifier(ThemedBackground(grouped: true))
+ .onDisappear {
+ chatModel.showingTerminal = false
+ chatModel.terminalItems = []
+ }
}
private func chatDatabaseRow() -> some View {
@@ -463,7 +476,11 @@ struct SettingsView: View {
case .registered:
icon = "bolt.fill"
color = theme.colors.secondary
- case .invalid:
+ case .invalid: fallthrough
+ case .invalidBad: fallthrough
+ case .invalidTopic: fallthrough
+ case .invalidExpired: fallthrough
+ case .invalidUnregistered:
icon = "bolt.slash"
color = theme.colors.secondary
case .confirmed:
@@ -510,7 +527,7 @@ struct ProfilePreview: View {
func profileName(_ profileOf: NamedChat) -> Text {
var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2)
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
- t = t + Text(" (" + profileOf.fullName + ")")
+ t = t + Text(verbatim: " (" + profileOf.fullName + ")")
// .font(.callout)
}
return t
diff --git a/apps/ios/Shared/Views/UserSettings/StorageView.swift b/apps/ios/Shared/Views/UserSettings/StorageView.swift
new file mode 100644
index 0000000000..094c1cb3d6
--- /dev/null
+++ b/apps/ios/Shared/Views/UserSettings/StorageView.swift
@@ -0,0 +1,56 @@
+//
+// StorageView.swift
+// SimpleX (iOS)
+//
+// Created by Stanislav Dmitrenko on 13.01.2025.
+// Copyright © 2025 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct StorageView: View {
+ @State var appGroupFiles: [String: Int64] = [:]
+ @State var documentsFiles: [String: Int64] = [:]
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading) {
+ directoryView("App group:", appGroupFiles)
+ if !documentsFiles.isEmpty {
+ directoryView("Documents:", documentsFiles)
+ }
+ }
+ }
+ .padding()
+ .onAppear {
+ appGroupFiles = traverseFiles(in: getGroupContainerDirectory())
+ documentsFiles = traverseFiles(in: getDocumentsDirectory())
+ }
+ }
+
+ @ViewBuilder
+ private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View {
+ Text(name).font(.headline)
+ ForEach(Array(contents), id: \.key) { (key, value) in
+ Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary)))
+ }
+ }
+
+ private func traverseFiles(in dir: URL) -> [String: Int64] {
+ var res: [String: Int64] = [:]
+ let fm = FileManager.default
+ do {
+ if let enumerator = fm.enumerator(at: dir, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .fileAllocatedSizeKey]) {
+ for case let url as URL in enumerator {
+ let attrs = try url.resourceValues(forKeys: [/*.isDirectoryKey, .fileSizeKey,*/ .fileAllocatedSizeKey])
+ let root = String(url.absoluteString.replacingOccurrences(of: dir.absoluteString, with: "").split(separator: "/")[0])
+ res[root] = (res[root] ?? 0) + Int64(attrs.fileAllocatedSize ?? 0)
+ }
+ }
+ } catch {
+ logger.error("Error traversing files: \(error)")
+ }
+ return res
+ }
+}
diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift b/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift
index 15f6a1c7d7..6c1ea8deb2 100644
--- a/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift
@@ -9,15 +9,94 @@
import SwiftUI
struct UserAddressLearnMore: View {
+ @State var showCreateAddressButton = false
+ @State private var createAddressLinkActive = false
+ @State private var createOneTimeLinkActive = 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: 12) {
+ (Text(Image(systemName: "envelope")).foregroundColor(.secondary) + textSpace + Text("Share address publicly").bold().font(.title2))
+ Text("Share SimpleX address on social media.")
+ Text("You won't lose your contacts if you later delete your address.")
+
+ (Text(Image(systemName: "link.badge.plus")).foregroundColor(.secondary) + textSpace + Text("Share 1-time link with a friend").font(.title2).bold())
+ .padding(.top)
+ Text("1-time link can be used *with one contact only* - share in person or via any messenger.")
+ Text("You can set connection name, to remember who the link was shared with.")
+
+ if !showCreateAddressButton {
+ (Text(Image(systemName: "shield")).foregroundColor(.secondary) + textSpace + Text("Connection security").font(.title2).bold())
+ .padding(.top)
+ Text("SimpleX address and 1-time links are safe to share via any messenger.")
+ Text("To protect against your link being replaced, you can compare contact security codes.")
+ Text("Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).")
+ .padding(.top)
+ }
+
+ }
+ .listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
- .listRowBackground(Color.clear)
+ .frame(maxHeight: .infinity, alignment: .top)
+
+ Spacer()
+
+ if showCreateAddressButton {
+ VStack {
+ addressCreationButton()
+ .padding(.bottom)
+
+ createOneTimeLinkButton()
+ }
+ .padding()
+ }
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+
+ 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()
+ }
+ }
+
+ private func createOneTimeLinkButton() -> some View {
+ ZStack {
+ Button {
+ createOneTimeLinkActive = true
+ } label: {
+ Text("Create 1-time link")
+ .font(.callout)
+ }
+
+ NavigationLink(isActive: $createOneTimeLinkActive) {
+ NewChatView(selection: .invite)
+ .navigationTitle("New chat")
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
}
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
index 2469dc59db..4813edf96c 100644
--- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
@@ -8,52 +8,39 @@
import SwiftUI
import MessageUI
-import SimpleXChat
+@preconcurrency import SimpleXChat
struct UserAddressView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject private var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State var shareViaProfile = false
+ @State var autoCreate = false
+ @State private var showShortLink = true
@State private var aas = AutoAcceptState()
@State private var savedAAS = AutoAcceptState()
- @State private var ignoreShareViaProfileChange = false
@State private var showMailView = false
@State private var mailViewResult: Result? = nil
@State private var alert: UserAddressAlert?
@State private var progressIndicator = false
- @FocusState private var keyboardVisible: Bool
private enum UserAddressAlert: Identifiable {
case deleteAddress
- case profileAddress(on: Bool)
case shareOnCreate
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
case .deleteAddress: return "deleteAddress"
- case let .profileAddress(on): return "profileAddress \(on)"
case .shareOnCreate: return "shareOnCreate"
case let .error(title, _): return "error \(title)"
}
}
}
-
+
var body: some View {
ZStack {
- userAddressScrollView()
- .onDisappear {
- if savedAAS != aas {
- showAlert(
- title: NSLocalizedString("Auto-accept settings", comment: "alert title"),
- message: NSLocalizedString("Settings were changed.", comment: "alert message"),
- buttonTitle: NSLocalizedString("Save", comment: "alert button"),
- buttonAction: saveAAS,
- cancelButton: true
- )
- }
- }
+ userAddressView()
if progressIndicator {
ZStack {
@@ -67,22 +54,10 @@ struct UserAddressView: View {
}
}
}
- }
-
- @Namespace private var bottomID
-
- private func userAddressScrollView() -> some View {
- ScrollViewReader { proxy in
- userAddressView()
- .onChange(of: keyboardVisible) { _ in
- if keyboardVisible {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
- withAnimation {
- proxy.scrollTo(bottomID, anchor: .top)
- }
- }
- }
- }
+ .onAppear {
+ if chatModel.userAddress == nil, autoCreate {
+ createAddress()
+ }
}
}
@@ -94,14 +69,18 @@ struct UserAddressView: View {
aas = AutoAcceptState(userAddress: userAddress)
savedAAS = aas
}
- .onChange(of: aas.enable) { _ in
- if !aas.enable { aas = AutoAcceptState() }
- }
} else {
Section {
createAddressButton()
- } footer: {
- Text("Create an address to let people connect with you.")
+ } header: {
+ Text("For social media")
+ .foregroundColor(theme.colors.secondary)
+ }
+
+ Section {
+ createOneTimeLinkButton()
+ } header: {
+ Text("Or to share privately")
.foregroundColor(theme.colors.secondary)
}
@@ -117,8 +96,8 @@ struct UserAddressView: View {
title: Text("Delete address?"),
message:
shareViaProfile
- ? Text("All your contacts will remain connected. Profile update will be sent to your contacts.")
- : Text("All your contacts will remain connected."),
+ ? Text("All your contacts will remain connected. Profile update will be sent to your contacts.")
+ : Text("All your contacts will remain connected."),
primaryButton: .destructive(Text("Delete")) {
progressIndicator = true
Task {
@@ -128,7 +107,6 @@ struct UserAddressView: View {
chatModel.userAddress = nil
chatModel.updateUser(u)
if shareViaProfile {
- ignoreShareViaProfileChange = true
shareViaProfile = false
}
}
@@ -141,37 +119,12 @@ struct UserAddressView: View {
}
}, secondaryButton: .cancel()
)
- case let .profileAddress(on):
- if on {
- return Alert(
- title: Text("Share address with contacts?"),
- message: Text("Profile update will be sent to your contacts."),
- primaryButton: .default(Text("Share")) {
- setProfileAddress(on)
- }, secondaryButton: .cancel() {
- ignoreShareViaProfileChange = true
- shareViaProfile = !on
- }
- )
- } else {
- return Alert(
- title: Text("Stop sharing address?"),
- message: Text("Profile update will be sent to your contacts."),
- primaryButton: .default(Text("Stop sharing")) {
- setProfileAddress(on)
- }, secondaryButton: .cancel() {
- ignoreShareViaProfileChange = true
- shareViaProfile = !on
- }
- )
- }
case .shareOnCreate:
return Alert(
title: Text("Share address with contacts?"),
message: Text("Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts."),
primaryButton: .default(Text("Share")) {
- setProfileAddress(true)
- ignoreShareViaProfileChange = true
+ setProfileAddress($progressIndicator, true)
shareViaProfile = true
}, secondaryButton: .cancel()
)
@@ -183,22 +136,41 @@ struct UserAddressView: View {
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
Section {
- SimpleXLinkQRCode(uri: userAddress.connReqContact)
- .id("simplex-contact-address-qrcode-\(userAddress.connReqContact)")
+ SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: $showShortLink)
+ .id("simplex-contact-address-qrcode-\(userAddress.connLinkContact.simplexChatUri(short: showShortLink))")
shareQRCodeButton(userAddress)
- if MFMailComposeViewController.canSendMail() {
- shareViaEmailButton(userAddress)
+ // if MFMailComposeViewController.canSendMail() {
+ // shareViaEmailButton(userAddress)
+ // }
+ settingsRow("briefcase", color: theme.colors.secondary) {
+ Toggle("Business address", isOn: $aas.business)
+ .onChange(of: aas.business) { ba in
+ if ba {
+ aas.enable = true
+ aas.incognito = false
+ }
+ saveAAS($aas, $savedAAS)
+ }
}
- shareWithContactsButton()
- autoAcceptToggle()
- learnMoreButton()
+ addressSettingsButton(userAddress)
} header: {
- Text("Address")
+ ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink)
+ } footer: {
+ if aas.business {
+ Text("Add your team members to the conversations.")
+ .foregroundColor(theme.colors.secondary)
+ }
+ }
+
+ Section {
+ createOneTimeLinkButton()
+ } header: {
+ Text("Or to share privately")
.foregroundColor(theme.colors.secondary)
}
- if aas.enable {
- autoAcceptSection()
+ Section {
+ learnMoreButton()
}
Section {
@@ -207,32 +179,48 @@ struct UserAddressView: View {
Text("Your contacts will remain connected.")
.foregroundColor(theme.colors.secondary)
}
- .id(bottomID)
}
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 short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)
+ let connLinkContact = try await apiCreateUserAddress(short: short)
+ DispatchQueue.main.async {
+ chatModel.userAddress = UserContactLink(connLinkContact: connLinkContact)
+ 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 createOneTimeLinkButton() -> some View {
+ NavigationLink {
+ NewChatView(selection: .invite)
+ .navigationTitle("New chat")
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ Label("Create 1-time link", systemImage: "link.badge.plus")
+ .foregroundColor(theme.colors.primary)
+ }
+ }
+
private func deleteAddressButton() -> some View {
Button(role: .destructive) {
alert = .deleteAddress
@@ -244,7 +232,7 @@ struct UserAddressView: View {
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
Button {
- showShareSheet(items: [simplexChatLink(userAddress.connReqContact)])
+ showShareSheet(items: [simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: showShortLink))])
} label: {
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
Text("Share address")
@@ -282,24 +270,162 @@ struct UserAddressView: View {
}
}
- private func autoAcceptToggle() -> some View {
- settingsRow("checkmark", color: theme.colors.secondary) {
- Toggle("Auto-accept", isOn: $aas.enable)
- .onChange(of: aas.enable) { _ in
- saveAAS()
- }
+ private func addressSettingsButton(_ userAddress: UserContactLink) -> some View {
+ NavigationLink {
+ UserAddressSettingsView(shareViaProfile: $shareViaProfile)
+ .navigationTitle("Address settings")
+ .navigationBarTitleDisplayMode(.large)
+ .modifier(ThemedBackground(grouped: true))
+ } label: {
+ Text("Address settings")
}
}
private func learnMoreButton() -> some View {
NavigationLink {
UserAddressLearnMore()
- .navigationTitle("SimpleX address")
+ .navigationTitle("Address or 1-time link?")
.modifier(ThemedBackground(grouped: true))
- .navigationBarTitleDisplayMode(.large)
+ .navigationBarTitleDisplayMode(.inline)
} label: {
settingsRow("info.circle", color: theme.colors.secondary) {
- Text("About SimpleX address")
+ Text("SimpleX address or 1-time link?")
+ }
+ }
+ }
+}
+
+struct ToggleShortLinkHeader: View {
+ @EnvironmentObject var theme: AppTheme
+ let text: Text
+ var link: CreatedConnLink
+ @Binding var short: Bool
+
+ var body: some View {
+ if link.connShortLink == nil {
+ text.foregroundColor(theme.colors.secondary)
+ } else {
+ HStack {
+ text.foregroundColor(theme.colors.secondary)
+ Spacer()
+ Text(short ? "Full link" : "Short link")
+ .textCase(.none)
+ .foregroundColor(theme.colors.primary)
+ .onTapGesture { short.toggle() }
+ }
+ }
+ }
+}
+
+private struct AutoAcceptState: Equatable {
+ var enable = false
+ var incognito = false
+ var business = false
+ var welcomeText = ""
+
+ init(enable: Bool = false, incognito: Bool = false, business: Bool = false, welcomeText: String = "") {
+ self.enable = enable
+ self.incognito = incognito
+ self.business = business
+ self.welcomeText = welcomeText
+ }
+
+ init(userAddress: UserContactLink) {
+ if let aa = userAddress.autoAccept {
+ enable = true
+ incognito = aa.acceptIncognito
+ business = aa.businessAddress
+ if let msg = aa.autoReply {
+ welcomeText = msg.text
+ } else {
+ welcomeText = ""
+ }
+ } else {
+ enable = false
+ incognito = false
+ business = false
+ welcomeText = ""
+ }
+ }
+
+ var autoAccept: AutoAccept? {
+ if enable {
+ var autoReply: MsgContent? = nil
+ let s = welcomeText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if s != "" { autoReply = .text(s) }
+ return AutoAccept(businessAddress: business, acceptIncognito: incognito, autoReply: autoReply)
+ }
+ return nil
+ }
+}
+
+private func setProfileAddress(_ progressIndicator: Binding, _ on: Bool) {
+ progressIndicator.wrappedValue = true
+ Task {
+ do {
+ if let u = try await apiSetProfileAddress(on: on) {
+ DispatchQueue.main.async {
+ ChatModel.shared.updateUser(u)
+ }
+ }
+ await MainActor.run { progressIndicator.wrappedValue = false }
+ } catch let error {
+ logger.error("apiSetProfileAddress: \(responseError(error))")
+ await MainActor.run { progressIndicator.wrappedValue = false }
+ }
+ }
+}
+
+struct UserAddressSettingsView: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var theme: AppTheme
+ @Binding var shareViaProfile: Bool
+ @State private var aas = AutoAcceptState()
+ @State private var savedAAS = AutoAcceptState()
+ @State private var ignoreShareViaProfileChange = false
+ @State private var progressIndicator = false
+ @FocusState private var keyboardVisible: Bool
+
+ var body: some View {
+ ZStack {
+ if let userAddress = ChatModel.shared.userAddress {
+ userAddressSettingsView()
+ .onAppear {
+ aas = AutoAcceptState(userAddress: userAddress)
+ savedAAS = aas
+ }
+ .onChange(of: aas.enable) { aasEnabled in
+ if !aasEnabled { aas = AutoAcceptState() }
+ }
+ .onDisappear {
+ if savedAAS != aas {
+ showAlert(
+ title: NSLocalizedString("Auto-accept settings", comment: "alert title"),
+ message: NSLocalizedString("Settings were changed.", comment: "alert message"),
+ buttonTitle: NSLocalizedString("Save", comment: "alert button"),
+ buttonAction: { saveAAS($aas, $savedAAS) },
+ cancelButton: true
+ )
+ }
+ }
+ } else {
+ Text(String("Error opening address settings"))
+ }
+ if progressIndicator {
+ ProgressView().scaleEffect(2)
+ }
+ }
+ }
+
+ private func userAddressSettingsView() -> some View {
+ List {
+ Section {
+ shareWithContactsButton()
+ autoAcceptToggle().disabled(aas.business)
+ }
+
+ if aas.enable {
+ autoAcceptSection()
}
}
}
@@ -311,70 +437,70 @@ struct UserAddressView: View {
if ignoreShareViaProfileChange {
ignoreShareViaProfileChange = false
} else {
- alert = .profileAddress(on: on)
+ if on {
+ showAlert(
+ NSLocalizedString("Share address with contacts?", comment: "alert title"),
+ message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ ignoreShareViaProfileChange = true
+ shareViaProfile = !on
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Share", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ setProfileAddress($progressIndicator, on)
+ }
+ )
+ ]}
+ )
+ } else {
+ showAlert(
+ NSLocalizedString("Stop sharing address?", comment: "alert title"),
+ message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"),
+ actions: {[
+ UIAlertAction(
+ title: NSLocalizedString("Cancel", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ ignoreShareViaProfileChange = true
+ shareViaProfile = !on
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString("Stop sharing", comment: "alert action"),
+ style: .default,
+ handler: { _ in
+ setProfileAddress($progressIndicator, on)
+ }
+ )
+ ]}
+ )
+ }
}
}
}
}
- private func setProfileAddress(_ on: Bool) {
- progressIndicator = true
- Task {
- do {
- if let u = try await apiSetProfileAddress(on: on) {
- DispatchQueue.main.async {
- chatModel.updateUser(u)
- }
+ private func autoAcceptToggle() -> some View {
+ settingsRow("checkmark", color: theme.colors.secondary) {
+ Toggle("Auto-accept", isOn: $aas.enable)
+ .onChange(of: aas.enable) { _ in
+ saveAAS($aas, $savedAAS)
}
- await MainActor.run { progressIndicator = false }
- } catch let error {
- logger.error("UserAddressView apiSetProfileAddress: \(responseError(error))")
- await MainActor.run { progressIndicator = false }
- }
- }
- }
-
- private struct AutoAcceptState: Equatable {
- var enable = false
- var incognito = false
- var welcomeText = ""
-
- init(enable: Bool = false, incognito: Bool = false, welcomeText: String = "") {
- self.enable = enable
- self.incognito = incognito
- self.welcomeText = welcomeText
- }
-
- init(userAddress: UserContactLink) {
- if let aa = userAddress.autoAccept {
- enable = true
- incognito = aa.acceptIncognito
- if let msg = aa.autoReply {
- welcomeText = msg.text
- } else {
- welcomeText = ""
- }
- } else {
- enable = false
- incognito = false
- welcomeText = ""
- }
- }
-
- var autoAccept: AutoAccept? {
- if enable {
- var autoReply: MsgContent? = nil
- let s = welcomeText.trimmingCharacters(in: .whitespacesAndNewlines)
- if s != "" { autoReply = .text(s) }
- return AutoAccept(acceptIncognito: incognito, autoReply: autoReply)
- }
- return nil
}
}
- @ViewBuilder private func autoAcceptSection() -> some View {
+ private func autoAcceptSection() -> some View {
Section {
- acceptIncognitoToggle()
+ if !aas.business {
+ acceptIncognitoToggle()
+ }
welcomeMessageEditor()
saveAASButton()
.disabled(aas == savedAAS)
@@ -414,22 +540,24 @@ struct UserAddressView: View {
private func saveAASButton() -> some View {
Button {
keyboardVisible = false
- saveAAS()
+ saveAAS($aas, $savedAAS)
} label: {
Text("Save")
}
}
+}
- private func saveAAS() {
- Task {
- do {
- if let address = try await userAddressAutoAccept(aas.autoAccept) {
- chatModel.userAddress = address
- savedAAS = aas
+private func saveAAS(_ aas: Binding, _ savedAAS: Binding) {
+ Task {
+ do {
+ if let address = try await userAddressAutoAccept(aas.wrappedValue.autoAccept) {
+ await MainActor.run {
+ ChatModel.shared.userAddress = address
+ savedAAS.wrappedValue = aas.wrappedValue
}
- } catch let error {
- logger.error("userAddressAutoAccept error: \(responseError(error))")
}
+ } catch let error {
+ logger.error("userAddressAutoAccept error: \(responseError(error))")
}
}
}
@@ -437,7 +565,7 @@ struct UserAddressView: View {
struct UserAddressView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
- chatModel.userAddress = UserContactLink(connReqContact: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
+ chatModel.userAddress = UserContactLink(connLinkContact: CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil))
return Group {
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
index 8a70efbe82..9aa42930bf 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
@@ -133,7 +133,6 @@ struct UserProfile: View {
.alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) }
}
- @ViewBuilder
private func overlayButton(
_ systemName: String,
edge: Edge.Set,
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift
index 330ce56e0b..887023b670 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift
@@ -21,6 +21,7 @@ struct UserProfilesView: View {
@State private var profileHidden = false
@State private var profileAction: UserProfileAction?
@State private var actionPassword = ""
+ @State private var navigateToProfileCreate = false
var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)}
@@ -55,17 +56,6 @@ struct UserProfilesView: View {
}
var body: some View {
- if authorized {
- userProfilesView()
- } else {
- Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
- .onAppear(perform: runAuth)
- }
- }
-
- private func runAuth() { authorize(NSLocalizedString("Open user profiles", comment: "authentication reason"), $authorized) }
-
- private func userProfilesView() -> some View {
List {
if profileHidden {
Button {
@@ -77,12 +67,14 @@ struct UserProfilesView: View {
Section {
let users = filteredUsers()
let v = ForEach(users) { u in
- userView(u.user)
+ userView(u)
}
if #available(iOS 16, *) {
v.onDelete { indexSet in
if let i = indexSet.first {
- confirmDeleteUser(users[i].user)
+ withAuth {
+ confirmDeleteUser(users[i].user)
+ }
}
}
} else {
@@ -90,12 +82,22 @@ struct UserProfilesView: View {
}
if trimmedSearchTextOrPassword == "" {
- NavigationLink {
- CreateProfile()
- } label: {
+ NavigationLink(
+ destination: CreateProfile(),
+ isActive: $navigateToProfileCreate
+ ) {
Label("Add profile", systemImage: "plus")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(height: 38)
+ .padding(.leading, 16).padding(.vertical, 8).padding(.trailing, 32)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ withAuth {
+ self.navigateToProfileCreate = true
+ }
+ }
+ .padding(.leading, -16).padding(.vertical, -8).padding(.trailing, -32)
}
- .frame(height: 38)
}
} footer: {
Text("Tap to activate profile.")
@@ -189,7 +191,25 @@ struct UserProfilesView: View {
private var visibleUsersCount: Int {
m.users.filter({ u in !u.user.hidden }).count
}
-
+
+ private func withAuth(_ action: @escaping () -> Void) {
+ if authorized {
+ action()
+ } else {
+ authenticate(
+ reason: NSLocalizedString("Change chat profiles", comment: "authentication reason")
+ ) { laResult in
+ switch laResult {
+ case .success, .unavailable:
+ authorized = true
+ AppSheetState.shared.scenePhaseActive = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: action)
+ case .failed: authorized = false
+ }
+ }
+ }
+ }
+
private func correctPassword(_ user: User, _ pwd: String) -> Bool {
if let ph = user.viewPwdHash {
return pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
@@ -201,11 +221,11 @@ struct UserProfilesView: View {
!user.hidden ? nil : trimmedSearchTextOrPassword
}
- @ViewBuilder private func profileActionView(_ action: UserProfileAction) -> some View {
+ private func profileActionView(_ action: UserProfileAction) -> some View {
let passwordValid = actionPassword == actionPassword.trimmingCharacters(in: .whitespaces)
let passwordField = PassphraseField(key: $actionPassword, placeholder: "Profile password", valid: passwordValid)
let actionEnabled: (User) -> Bool = { user in actionPassword != "" && passwordValid && correctPassword(user, actionPassword) }
- List {
+ return List {
switch action {
case let .deleteUser(user, delSMPQueues):
actionHeader("Delete profile", user)
@@ -213,8 +233,10 @@ struct UserProfilesView: View {
passwordField
settingsRow("trash", color: theme.colors.secondary) {
Button("Delete chat profile", role: .destructive) {
- profileAction = nil
- Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) }
+ withAuth {
+ profileAction = nil
+ Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) }
+ }
}
.disabled(!actionEnabled(user))
}
@@ -231,8 +253,10 @@ struct UserProfilesView: View {
passwordField
settingsRow("lock.open", color: theme.colors.secondary) {
Button("Unhide chat profile") {
- profileAction = nil
- setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) }
+ withAuth{
+ profileAction = nil
+ setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) }
+ }
}
.disabled(!actionEnabled(user))
}
@@ -255,11 +279,13 @@ struct UserProfilesView: View {
private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View {
Button(title, role: .destructive) {
- if let user = userToDelete {
- if passwordEntryRequired(user) {
- profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues)
- } else {
- alert = .deleteUser(user: user, delSMPQueues: delSMPQueues)
+ withAuth {
+ if let user = userToDelete {
+ if passwordEntryRequired(user) {
+ profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues)
+ } else {
+ alert = .deleteUser(user: user, delSMPQueues: delSMPQueues)
+ }
}
}
}
@@ -272,6 +298,7 @@ struct UserProfilesView: View {
private func removeUser(_ user: User, _ delSMPQueues: Bool, viewPwd: String?) async {
do {
if user.activeUser {
+ ChatModel.shared.removeWallpaperFilesFromAllChats(user)
if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) {
try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil)
try await deleteUser()
@@ -297,11 +324,13 @@ struct UserProfilesView: View {
func deleteUser() async throws {
try await apiDeleteUser(user.userId, delSMPQueues, viewPwd: viewPwd)
+ removeWallpaperFilesFromTheme(user.uiThemes)
await MainActor.run { withAnimation { m.removeUser(user) } }
}
}
- @ViewBuilder private func userView(_ user: User) -> some View {
+ @ViewBuilder private func userView(_ userInfo: UserInfo) -> some View {
+ let user = userInfo.user
let v = Button {
Task {
do {
@@ -319,12 +348,19 @@ struct UserProfilesView: View {
Spacer()
if user.activeUser {
Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground)
- } else if user.hidden {
- Image(systemName: "lock").foregroundColor(theme.colors.secondary)
- } else if !user.showNtfs {
- Image(systemName: "speaker.slash").foregroundColor(theme.colors.secondary)
} else {
- Image(systemName: "checkmark").foregroundColor(.clear)
+ if userInfo.unreadCount > 0 {
+ UnreadBadge(userInfo: userInfo)
+ }
+ if user.hidden {
+ Image(systemName: "lock").foregroundColor(theme.colors.secondary)
+ } else if userInfo.unreadCount == 0 {
+ if !user.showNtfs {
+ Image(systemName: "speaker.slash").foregroundColor(theme.colors.secondary)
+ } else {
+ Image(systemName: "checkmark").foregroundColor(.clear)
+ }
+ }
}
}
}
@@ -332,30 +368,38 @@ struct UserProfilesView: View {
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if user.hidden {
Button("Unhide") {
- if passwordEntryRequired(user) {
- profileAction = .unhideUser(user: user)
- } else {
- setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: trimmedSearchTextOrPassword) }
+ withAuth {
+ if passwordEntryRequired(user) {
+ profileAction = .unhideUser(user: user)
+ } else {
+ setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: trimmedSearchTextOrPassword) }
+ }
}
}
.tint(.green)
} else {
if visibleUsersCount > 1 {
Button("Hide") {
- selectedUser = user
+ withAuth {
+ selectedUser = user
+ }
}
.tint(.gray)
}
Group {
if user.showNtfs {
Button("Mute") {
- setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) {
- try await apiMuteUser(user.userId)
+ withAuth {
+ setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) {
+ try await apiMuteUser(user.userId)
+ }
}
}
} else {
Button("Unmute") {
- setUserPrivacy(user) { try await apiUnmuteUser(user.userId) }
+ withAuth {
+ setUserPrivacy(user) { try await apiUnmuteUser(user.userId) }
+ }
}
}
}
@@ -367,7 +411,9 @@ struct UserProfilesView: View {
} else {
v.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", role: .destructive) {
- confirmDeleteUser(user)
+ withAuth {
+ confirmDeleteUser(user)
+ }
}
}
}
diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements
index c78a7cb941..2ec32def0a 100644
--- a/apps/ios/SimpleX (iOS).entitlements
+++ b/apps/ios/SimpleX (iOS).entitlements
@@ -9,6 +9,10 @@
applinks:simplex.chat
applinks:www.simplex.chat
applinks:simplex.chat?mode=developer
+ applinks:*.simplex.im
+ applinks:*.simplex.im?mode=developer
+ applinks:*.simplexonflux.com
+ applinks:*.simplexonflux.com?mode=developer
com.apple.security.application-groups
diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
index 40481d81f1..e965e5a1a5 100644
--- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
+++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
@@ -49,7 +49,7 @@
%@
- %@
+ %@
No comment provided by engineer.
@@ -187,23 +187,18 @@
)
No comment provided by engineer.
-
- **Add new contact**: to create your one-time QR Code or link for your contact.
- ** إضافة جهة اتصال جديدة **: لإنشاء رمز QR لمرة واحدة أو رابط جهة الاتصال الخاصة بكم.
- No comment provided by engineer.
-
**Create link / QR code** for your contact to use.
** أنشئ رابطًا / رمز QR ** لتستخدمه جهة الاتصال الخاصة بك.
No comment provided by engineer.
-
- **More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have.
+
+ **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata.
** المزيد من الخصوصية **: تحققوا من الرسائل الجديدة كل 20 دقيقة. تتم مشاركة رمز الجهاز مع خادم SimpleX Chat ، ولكن ليس عدد جهات الاتصال أو الرسائل لديكم.
No comment provided by engineer.
-
- **Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app).
+
+ **Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app.
** الأكثر خصوصية **: لا تستخدم خادم إشعارات SimpleX Chat ، وتحقق من الرسائل بشكل دوري في الخلفية (يعتمد على عدد مرات استخدامكم للتطبيق).
No comment provided by engineer.
@@ -217,8 +212,8 @@
** يرجى ملاحظة **: لن تتمكنوا من استعادة أو تغيير عبارة المرور إذا فقدتموها.
No comment provided by engineer.
-
- **Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.
+
+ **Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from.
** موصى به **: يتم إرسال رمز الجهاز والإشعارات إلى خادم إشعارات SimpleX Chat ، ولكن ليس محتوى الرسالة أو حجمها أو مصدرها.
No comment provided by engineer.
@@ -384,292 +379,356 @@
أضف إلى جهاز آخر
No comment provided by engineer.
-
+
Admins can create the links to join groups.
+ يمكن للمُدراء إنشاء روابط للانضمام إلى المجموعات.
No comment provided by engineer.
-
+
Advanced network settings
+ إعدادات الشبكة المتقدمة
No comment provided by engineer.
-
+
All chats and messages will be deleted - this cannot be undone!
+ سيتم حذف جميع الدردشات والرسائل - لا يمكن التراجع عن هذا!
No comment provided by engineer.
-
+
All group members will remain connected.
+ سيبقى جميع أعضاء المجموعة على اتصال.
No comment provided by engineer.
-
+
All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you.
+ سيتم حذف جميع الرسائل - لا يمكن التراجع عن هذا! سيتم حذف الرسائل فقط من أجلك.
No comment provided by engineer.
All your contacts will remain connected
No comment provided by engineer.
-
+
Allow
+ سماح
No comment provided by engineer.
-
+
Allow disappearing messages only if your contact allows it to you.
+ السماح بالرسائل المختفية فقط إذا سمحت لك جهة الاتصال بذلك.
No comment provided by engineer.
Allow irreversible message deletion only if your contact allows it to you.
No comment provided by engineer.
-
+
Allow sending direct messages to members.
+ السماح بإرسال رسائل مباشرة إلى الأعضاء.
No comment provided by engineer.
-
+
Allow sending disappearing messages.
+ السماح بإرسال الرسائل التي تختفي.
No comment provided by engineer.
Allow to irreversibly delete sent messages.
No comment provided by engineer.
-
+
Allow to send voice messages.
+ السماح بإرسال رسائل صوتية.
No comment provided by engineer.
-
+
Allow voice messages only if your contact allows them.
+ اسمح بالرسائل الصوتية فقط إذا سمحت جهة اتصالك بذلك.
No comment provided by engineer.
-
+
Allow voice messages?
+ السماح بالرسائل الصوتية؟
No comment provided by engineer.
Allow your contacts to irreversibly delete sent messages.
No comment provided by engineer.
-
+
Allow your contacts to send disappearing messages.
+ السماح لجهات اتصالك بإرسال رسائل تختفي.
No comment provided by engineer.
-
+
Allow your contacts to send voice messages.
+ اسمح لجهات اتصالك بإرسال رسائل صوتية.
No comment provided by engineer.
-
+