// // ChatRelayView.swift // SimpleX (iOS) // // Created by spaced4ndy on 23.02.2026. // Copyright © 2026 SimpleX Chat. All rights reserved. // // Spec: spec/architecture.md import SwiftUI import SimpleXChat @ViewBuilder func showRelayTestStatus(relay: UserChatRelay) -> some View { switch relay.tested { case .some(true): Image(systemName: "checkmark").foregroundColor(.green) case .some(false): Image(systemName: "multiply").foregroundColor(.red) case .none: Color.clear } } func validRelayName(_ name: String) -> Bool { name != "" && validDisplayName(name) } func showInvalidRelayNameAlert(_ name: Binding) { let validName = mkValidName(name.wrappedValue) if validName == "" { showAlert(NSLocalizedString("Invalid name!", comment: "alert title")) } else { showAlert( NSLocalizedString("Invalid name!", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName), actions: {[ UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in name.wrappedValue = validName }, cancelAlertAction ]} ) } } func validRelayAddress(_ address: String) -> Bool { if let parsedMd = parseSimpleXMarkdown(address), parsedMd.count == 1, case .simplexLink(_, .relay, _, _) = parsedMd.first?.format { true } else { false } } func addChatRelay( _ relay: UserChatRelay, _ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>, _ serverWarnings: Binding<[UserServersWarning]>? = nil, _ dismiss: DismissAction ) { let nameEmpty = relay.displayName.trimmingCharacters(in: .whitespaces).isEmpty let addressEmpty = relay.address.trimmingCharacters(in: .whitespaces).isEmpty if nameEmpty && addressEmpty { dismiss() } else if !validRelayName(relay.displayName) { dismiss() showAlert( NSLocalizedString("Invalid relay name!", comment: "alert title"), message: NSLocalizedString("Check relay name and try again.", comment: "alert message") ) } else if !validRelayAddress(relay.address) { dismiss() showAlert( NSLocalizedString("Invalid relay address!", comment: "alert title"), message: NSLocalizedString("Check relay address and try again.", comment: "alert message") ) } else if let i = userServers.wrappedValue.firstIndex(where: { $0.operator == nil }) { userServers[i].wrappedValue.chatRelays.append(relay) validateServers_(userServers, serverErrors, serverWarnings) dismiss() } else { // Shouldn't happen dismiss() showAlert(NSLocalizedString("Error adding relay", comment: "alert title")) } } struct ChatRelayView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] @Binding var serverWarnings: [UserServersWarning] @Binding var relay: UserChatRelay @State var relayToEdit: UserChatRelay var backLabel: LocalizedStringKey @State private var showTestFailure = false @State private var testing = false @State private var testFailure: RelayTestFailure? var body: some View { let validName = validRelayName(relayToEdit.displayName) let validAddress = validRelayAddress(relayToEdit.address) ZStack { if relay.preset { presetRelay() } else { customRelay(validName: validName, validAddress: validAddress) } if testing { ProgressView().scaleEffect(2) } } .modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) { if validName && validAddress { relay = relayToEdit validateServers_($userServers, $serverErrors, $serverWarnings) dismiss() } else if !validName { dismiss() showAlert( NSLocalizedString("Invalid relay name!", comment: "alert title"), message: NSLocalizedString("Check relay name and try again.", comment: "alert message") ) } else { dismiss() showAlert( NSLocalizedString("Invalid relay address!", comment: "alert title"), message: NSLocalizedString("Check relay address and try again.", comment: "alert message") ) } }) .alert(isPresented: $showTestFailure) { Alert( title: Text("Relay test failed!"), message: Text(testFailure?.localizedDescription ?? "") ) } .onChange(of: relayToEdit.address) { _ in if relayToEdit.address == relay.address { relayToEdit.tested = relay.tested relayToEdit.displayName = relay.displayName } else { relayToEdit.tested = nil } } } private func relayNameHeader(validName: Bool) -> some View { HStack { Text("Your relay name").foregroundColor(theme.colors.secondary) if !validName { Spacer() Image(systemName: "exclamationmark.circle").foregroundColor(.red) .onTapGesture { showInvalidRelayNameAlert($relayToEdit.displayName) } } } } private func presetRelay() -> some View { List { Section(header: Text("Preset relay address").foregroundColor(theme.colors.secondary)) { Text(relayToEdit.address) .textSelection(.enabled) } Section(header: Text("Preset relay name").foregroundColor(theme.colors.secondary)) { Text(relayToEdit.displayName) } useRelaySection() } } private func customRelay(validName: Bool, validAddress: Bool) -> some View { List { Section { TextEditor(text: $relayToEdit.address) .multilineTextAlignment(.leading) .autocorrectionDisabled(true) .autocapitalization(.none) .allowsTightening(true) .lineLimit(10) .frame(height: 144) .padding(-6) } header: { HStack { Text("Your relay address") .foregroundColor(theme.colors.secondary) if !validAddress { Spacer() Image(systemName: "exclamationmark.circle").foregroundColor(.red) } } } Section { TextField("Enter relay name…", text: $relayToEdit.displayName) .autocorrectionDisabled(true) .disabled(relayToEdit.tested == true) } header: { relayNameHeader(validName: validName) } footer: { if relayToEdit.tested != true { Text("**Test relay** to retrieve its name.") } } useRelaySection(valid: validAddress) Section { Button(role: .destructive) { relay.deleted = true validateServers_($userServers, $serverErrors, $serverWarnings) dismiss() } label: { Label("Delete relay", systemImage: "trash") .foregroundColor(.red) } } } } private func useRelaySection(valid: Bool = true) -> some View { Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) { HStack { Button("Test relay") { testing = true relayToEdit.tested = nil Task { if let f = await testRelayConnection(relay: $relayToEdit) { showTestFailure = true testFailure = f } await MainActor.run { testing = false } } } .disabled(!valid || testing) Spacer() showRelayTestStatus(relay: relayToEdit) } Toggle("Use for new channels", isOn: $relayToEdit.enabled) } } } struct ChatRelayViewLink: View { @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] @Binding var serverWarnings: [UserServersWarning] @Binding var relay: UserChatRelay var duplicateRelayAddresses: Set var backLabel: LocalizedStringKey @Binding var selectedServer: String? var body: some View { NavigationLink(tag: relay.id, selection: $selectedServer) { ChatRelayView( userServers: $userServers, serverErrors: $serverErrors, serverWarnings: $serverWarnings, relay: $relay, relayToEdit: relay, backLabel: backLabel ) .navigationBarTitle("Chat relay") .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } label: { HStack { Group { if duplicateRelayAddresses.contains(relay.address) { Image(systemName: "exclamationmark.circle").foregroundColor(.red) } else if !relay.enabled { Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary) } else { showRelayTestStatus(relay: relay) } } .frame(width: 16, alignment: .center) .padding(.trailing, 4) let displayName = !relay.displayName.isEmpty ? relay.displayName : relay.domains.first ?? relay.address let v = Text(displayName).lineLimit(1) if relay.enabled { v } else { v.foregroundColor(theme.colors.secondary) } } } } } struct NewChatRelayView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] @Binding var serverWarnings: [UserServersWarning] @State private var relayToEdit = UserChatRelay( chatRelayId: nil, address: "", name: "", domains: [], preset: false, tested: nil, enabled: true, deleted: false ) @State private var showTestFailure = false @State private var testing = false @State private var testFailure: RelayTestFailure? var body: some View { let validName = validRelayName(relayToEdit.displayName) let validAddress = validRelayAddress(relayToEdit.address) ZStack { List { Section { TextEditor(text: $relayToEdit.address) .multilineTextAlignment(.leading) .autocorrectionDisabled(true) .autocapitalization(.none) .allowsTightening(true) .lineLimit(10) .frame(height: 144) .padding(-6) } header: { HStack { Text("Your relay address") .foregroundColor(theme.colors.secondary) if !validAddress { Spacer() Image(systemName: "exclamationmark.circle").foregroundColor(.red) } } } Section { TextField("Enter relay name…", text: $relayToEdit.displayName) .autocorrectionDisabled(true) .disabled(relayToEdit.tested == true) } header: { HStack { Text("Your relay name").foregroundColor(theme.colors.secondary) if !validName { Spacer() Image(systemName: "exclamationmark.circle").foregroundColor(.red) .onTapGesture { showInvalidRelayNameAlert($relayToEdit.displayName) } } } } footer: { if relayToEdit.tested != true { Text("**Test relay** to retrieve its name.") } } Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) { HStack { Button("Test relay") { testing = true relayToEdit.tested = nil Task { if let f = await testRelayConnection(relay: $relayToEdit) { showTestFailure = true testFailure = f } await MainActor.run { testing = false } } } .disabled(!validAddress || testing) Spacer() showRelayTestStatus(relay: relayToEdit) } Toggle("Use for new channels", isOn: $relayToEdit.enabled) } } if testing { ProgressView().scaleEffect(2) } } .modifier(BackButton(disabled: Binding.constant(false)) { addChatRelay(relayToEdit, $userServers, $serverErrors, $serverWarnings, dismiss) }) .alert(isPresented: $showTestFailure) { Alert( title: Text("Relay test failed!"), message: Text(testFailure?.localizedDescription ?? "") ) } .onChange(of: relayToEdit.address) { _ in relayToEdit.tested = nil } } } func testRelayConnection(relay: Binding) async -> RelayTestFailure? { do { let (relayProfile, testFailure) = try await testChatRelay(address: relay.wrappedValue.address) if let f = testFailure { await MainActor.run { relay.wrappedValue.tested = false } return f } await MainActor.run { relay.wrappedValue.tested = true if let relayProfile { relay.wrappedValue.displayName = relayProfile.displayName } } return nil } catch { logger.error("testRelayConnection \(responseError(error))") await MainActor.run { relay.wrappedValue.tested = false } return nil } }