Merge pull request #5111 from simplex-chat/server-operators

core, ui: add support for server operators
This commit is contained in:
Evgeny 2024-11-22 19:26:57 +00:00 committed by GitHub
commit 2f0fe50f88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 8265 additions and 2053 deletions

View file

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "Flux_logo_blue_white.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "Flux_logo_blue.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "Flux_symbol_blue-white.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -9,6 +9,18 @@ import SwiftUI
import Intents
import SimpleXChat
private enum NoticesSheet: Identifiable {
case whatsNew(updatedConditions: Bool)
case updatedConditions
var id: String {
switch self {
case .whatsNew: return "whatsNew"
case .updatedConditions: return "updatedConditions"
}
}
}
struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared
@ -30,7 +42,8 @@ struct ContentView: View {
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
@State private var showWhatsNew = false
@State private var noticesShown = false
@State private var noticesSheetItem: NoticesSheet? = nil
@State private var showChooseLAMode = false
@State private var showSetPasscode = false
@State private var waitingForOrPassedAuth = true
@ -261,8 +274,15 @@ struct ContentView: View {
alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if !showWhatsNew {
showWhatsNew = shouldShowWhatsNew()
if !noticesShown {
let showWhatsNew = shouldShowWhatsNew()
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
noticesShown = showWhatsNew || showUpdatedConditions
if showWhatsNew {
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
} else if showUpdatedConditions {
noticesSheetItem = .updatedConditions
}
}
}
}
@ -270,8 +290,22 @@ struct ContentView: View {
connectViaUrl()
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.sheet(isPresented: $showWhatsNew) {
WhatsNewView()
.sheet(item: $noticesSheetItem) { item in
switch item {
case let .whatsNew(updatedConditions):
WhatsNewView(updatedConditions: updatedConditions)
.modifier(ThemedBackground())
.if(updatedConditions) { v in
v.task { await setConditionsNotified_() }
}
case .updatedConditions:
UsageConditionsView(
currUserServers: Binding.constant([]),
userServers: Binding.constant([])
)
.modifier(ThemedBackground(grouped: true))
.task { await setConditionsNotified_() }
}
}
if chatModel.setDeliveryReceipts {
SetDeliveryReceiptsView()
@ -283,6 +317,15 @@ struct ContentView: View {
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity)
}
private func setConditionsNotified_() async {
do {
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
try await setConditionsNotified(conditionsId: conditionsId)
} catch let error {
logger.error("setConditionsNotified error: \(responseError(error))")
}
}
private func processUserActivity(_ activity: NSUserActivity) {
let intent = activity.interaction?.intent
if let intent = intent as? INStartCallIntent {

View file

@ -193,6 +193,8 @@ final class ChatModel: ObservableObject {
@Published var draft: ComposeState?
@Published var draftChatId: String?
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
// usage conditions
@Published var conditions: ServerOperatorConditions = .empty
var messageDelivery: Dictionary<Int64, () -> Void> = [:]

View file

@ -500,18 +500,6 @@ func apiDeleteToken(token: DeviceToken) async throws {
try await sendCommandOkResp(.apiDeleteToken(token: token))
}
func getUserProtoServers(_ serverProtocol: ServerProtocol) throws -> UserProtoServers {
let userId = try currentUserId("getUserProtoServers")
let r = chatSendCmdSync(.apiGetUserProtoServers(userId: userId, serverProtocol: serverProtocol))
if case let .userProtoServers(_, servers) = r { return servers }
throw r
}
func setUserProtoServers(_ serverProtocol: ServerProtocol, servers: [ServerCfg]) async throws {
let userId = try currentUserId("setUserProtoServers")
try await sendCommandOkResp(.apiSetUserProtoServers(userId: userId, serverProtocol: serverProtocol, servers: servers))
}
func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> {
let userId = try currentUserId("testProtoServer")
let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
@ -524,6 +512,65 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail
throw r
}
func getServerOperators() throws -> ServerOperatorConditions {
let r = chatSendCmdSync(.apiGetServerOperators)
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("getServerOperators error: \(String(describing: r))")
throw r
}
func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions {
let r = await chatSendCmd(.apiSetServerOperators(operators: operators))
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("setServerOperators error: \(String(describing: r))")
throw r
}
func getUserServers() async throws -> [UserOperatorServers] {
let userId = try currentUserId("getUserServers")
let r = await chatSendCmd(.apiGetUserServers(userId: userId))
if case let .userServers(_, userServers) = r { return userServers }
logger.error("getUserServers error: \(String(describing: r))")
throw r
}
func setUserServers(userServers: [UserOperatorServers]) async throws {
let userId = try currentUserId("setUserServers")
let r = await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers))
if case .cmdOk = r { return }
logger.error("setUserServers error: \(String(describing: r))")
throw r
}
func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] {
let userId = try currentUserId("validateServers")
let r = await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers))
if case let .userServersValidation(_, serverErrors) = r { return serverErrors }
logger.error("validateServers error: \(String(describing: r))")
throw r
}
func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) {
let r = await chatSendCmd(.apiGetUsageConditions)
if case let .usageConditions(usageConditions, conditionsText, acceptedConditions) = r { return (usageConditions, conditionsText, acceptedConditions) }
logger.error("getUsageConditions error: \(String(describing: r))")
throw r
}
func setConditionsNotified(conditionsId: Int64) async throws {
let r = await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId))
if case .cmdOk = r { return }
logger.error("setConditionsNotified error: \(String(describing: r))")
throw r
}
func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions {
let r = await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds))
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("acceptConditions error: \(String(describing: r))")
throw r
}
func getChatItemTTL() throws -> ChatItemTTL {
let userId = try currentUserId("getChatItemTTL")
return try chatItemTTLResponse(chatSendCmdSync(.apiGetChatItemTTL(userId: userId)))
@ -1558,6 +1605,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
m.chatInitialized = true
m.currentUser = try apiGetActiveUser()
m.conditions = try getServerOperators()
if m.currentUser == nil {
onboardingStageDefault.set(.step1_SimpleXInfo)
privacyDeliveryReceiptsSet.set(true)
@ -1602,7 +1650,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws {
}
}
func startChat(refreshInvitations: Bool = true) throws {
func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws {
logger.debug("startChat")
let m = ChatModel.shared
try setNetworkConfig(getNetCfg())
@ -1621,13 +1669,15 @@ func startChat(refreshInvitations: Bool = true) throws {
if let token = m.deviceToken {
registerToken(token: token)
}
withAnimation {
let savedOnboardingStage = onboardingStageDefault.get()
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
? .step3_CreateSimpleXAddress
: savedOnboardingStage
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
m.setDeliveryReceipts = true
if !onboarding {
withAnimation {
let savedOnboardingStage = onboardingStageDefault.get()
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
? .step3_ChooseServerOperators
: savedOnboardingStage
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
m.setDeliveryReceipts = true
}
}
}
}

View file

@ -31,9 +31,21 @@ enum UserPickerSheet: Identifiable {
}
}
class SaveableSettings: ObservableObject {
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
}
struct ServerSettings {
public var currUserServers: [UserOperatorServers]
public var userServers: [UserOperatorServers]
public var serverErrors: [UserServersError]
}
struct UserPickerSheetView: View {
let sheet: UserPickerSheet
@EnvironmentObject var chatModel: ChatModel
@StateObject private var ss = SaveableSettings()
@State private var loaded = false
var body: some View {
@ -76,6 +88,21 @@ struct UserPickerSheetView: View {
{ loaded = true }
)
}
.onDisappear {
if serversCanBeSaved(
ss.servers.currUserServers,
ss.servers.userServers,
ss.servers.serverErrors
) {
showAlert(
title: NSLocalizedString("Save servers?", comment: "alert title"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
buttonAction: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) },
cancelButton: true
)
}
}
.environmentObject(ss)
}
}
@ -94,6 +121,7 @@ struct ChatListView: View {
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View {
@ -276,12 +304,6 @@ struct ChatListView: View {
.padding(.top, oneHandUI ? 8 : 0)
.id("searchBar")
}
if !oneHandUICardShown {
OneHandUICard()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat)
@ -307,6 +329,20 @@ struct ChatListView: View {
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
}
}
if !oneHandUICardShown {
OneHandUICard()
.padding(.vertical, 6)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
if !addressCreationCardShown {
AddressCreationCard()
.padding(.vertical, 6)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
}
.listStyle(.plain)
.onChange(of: chatModel.chatId) { currentChatId in

View file

@ -32,7 +32,6 @@ struct OneHandUICard: View {
.background(theme.appColors.sentMessage)
.cornerRadius(12)
.frame(height: dynamicSize(userFont).rowHeight)
.padding(.vertical, 12)
.alert(isPresented: $showOneHandUIAlert) {
Alert(
title: Text("Reachable chat toolbar"),

View file

@ -491,15 +491,6 @@ struct SMPServerSummaryView: View {
Section("Server address") {
Text(summary.smpServer)
.textSelection(.enabled)
if summary.known == true {
NavigationLink {
ProtocolServersView(serverProtocol: .smp)
.navigationTitle("Your SMP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}
}
}
if let stats = summary.stats {
@ -679,15 +670,6 @@ struct XFTPServerSummaryView: View {
Section("Server address") {
Text(summary.xftpServer)
.textSelection(.enabled)
if summary.known == true {
NavigationLink {
ProtocolServersView(serverProtocol: .xftp)
.navigationTitle("Your XFTP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}
}
}
if let stats = summary.stats {

View file

@ -0,0 +1,115 @@
//
// AddressCreationCard.swift
// SimpleX (iOS)
//
// Created by Diogo Cunha on 13/11/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct AddressCreationCard: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject private var chatModel: ChatModel
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
@State private var showAddressCreationAlert = false
@State private var showAddressSheet = false
@State private var showAddressInfoSheet = false
var body: some View {
let addressExists = chatModel.userAddress != nil
let chats = chatModel.chats.filter { chat in
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
}
ZStack(alignment: .topTrailing) {
HStack(alignment: .top, spacing: 16) {
let envelopeSize = dynamicSize(userFont).profileImageSize
Image(systemName: "envelope.circle.fill")
.resizable()
.frame(width: envelopeSize, height: envelopeSize)
.foregroundColor(.accentColor)
VStack(alignment: .leading) {
Text("Your SimpleX address")
.font(.title3)
Spacer()
HStack(alignment: .center) {
Text("How to use it")
VStack {
Image(systemName: "info.circle")
.foregroundColor(theme.colors.secondary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .trailing) {
Image(systemName: "multiply")
.foregroundColor(theme.colors.secondary)
.onTapGesture {
showAddressCreationAlert = true
}
Spacer()
Text("Create")
.foregroundColor(.accentColor)
.onTapGesture {
showAddressSheet = true
}
}
}
.onTapGesture {
showAddressInfoSheet = true
}
.padding()
.background(theme.appColors.sentMessage)
.cornerRadius(12)
.frame(height: dynamicSize(userFont).rowHeight)
.alert(isPresented: $showAddressCreationAlert) {
Alert(
title: Text("SimpleX address"),
message: Text("You can create it in user picker."),
dismissButton: .default(Text("Ok")) {
withAnimation {
addressCreationCardShown = true
}
}
)
}
.sheet(isPresented: $showAddressSheet) {
NavigationView {
UserAddressView(autoCreate: true)
.navigationTitle("SimpleX address")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
}
}
.sheet(isPresented: $showAddressInfoSheet) {
NavigationView {
UserAddressLearnMore(showCreateAddressButton: true)
.navigationTitle("SimpleX address")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
}
}
.onChange(of: addressExists) { exists in
if exists, !addressCreationCardShown {
addressCreationCardShown = true
}
}
.onChange(of: chats.count) { size in
if size >= 3, !addressCreationCardShown {
addressCreationCardShown = true
}
}
.onAppear {
if addressExists, !addressCreationCardShown {
addressCreationCardShown = true
}
}
}
}
#Preview {
AddressCreationCard()
}

View file

@ -0,0 +1,436 @@
//
// ChooseServerOperators.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 31.10.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
let conditionsURL = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md")!
struct OnboardingButtonStyle: ButtonStyle {
@EnvironmentObject var theme: AppTheme
var isDisabled: Bool = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 17, weight: .semibold))
.padding()
.frame(maxWidth: .infinity)
.background(
isDisabled
? (
theme.colors.isLight
? .gray.opacity(0.17)
: .gray.opacity(0.27)
)
: theme.colors.primary
)
.foregroundColor(
isDisabled
? (
theme.colors.isLight
? .gray.opacity(0.4)
: .white.opacity(0.2)
)
: .white
)
.cornerRadius(16)
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
}
}
private enum ChooseServerOperatorsSheet: Identifiable {
case showInfo
case showConditions
var id: String {
switch self {
case .showInfo: return "showInfo"
case .showConditions: return "showConditions"
}
}
}
struct ChooseServerOperators: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Environment(\.colorScheme) var colorScheme: ColorScheme
@EnvironmentObject var theme: AppTheme
var onboarding: Bool
@State private var serverOperators: [ServerOperator] = []
@State private var selectedOperatorIds = Set<Int64>()
@State private var reviewConditionsNavLinkActive = false
@State private var sheetItem: ChooseServerOperatorsSheet? = nil
@State private var notificationsModeNavLinkActive = false
@State private var justOpened = true
var selectedOperators: [ServerOperator] { serverOperators.filter { selectedOperatorIds.contains($0.operatorId) } }
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if !onboarding {
Text("Choose operators")
.font(.largeTitle)
.bold()
}
infoText()
Spacer()
ForEach(serverOperators) { srvOperator in
operatorCheckView(srvOperator)
}
Text("You can configure servers via settings.")
.font(.footnote)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 32)
Spacer()
let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed }
let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId })
VStack(spacing: 8) {
if !reviewForOperators.isEmpty {
reviewConditionsButton()
} else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty {
setOperatorsButton()
} else {
continueButton()
}
if onboarding {
Group {
if reviewForOperators.isEmpty {
Button("Conditions of use") {
sheetItem = .showConditions
}
} else {
Text("Conditions of use")
.foregroundColor(.clear)
}
}
.font(.callout)
.padding(.top)
}
}
.padding(.bottom)
if !onboarding && !reviewForOperators.isEmpty {
VStack(spacing: 8) {
reviewLaterButton()
(
Text("Conditions will be accepted for enabled operators after 30 days.")
+ Text(" ")
+ Text("You can configure operators in Network & servers settings.")
)
.multilineTextAlignment(.center)
.font(.footnote)
.padding(.horizontal, 32)
}
.disabled(!canReviewLater)
.padding(.bottom)
}
}
.frame(minHeight: g.size.height)
}
.onAppear {
if justOpened {
serverOperators = ChatModel.shared.conditions.serverOperators
selectedOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId })
justOpened = false
}
}
.sheet(item: $sheetItem) { item in
switch item {
case .showInfo:
ChooseServerOperatorsInfoView()
case .showConditions:
UsageConditionsView(
currUserServers: Binding.constant([]),
userServers: Binding.constant([])
)
.modifier(ThemedBackground(grouped: true))
}
}
}
.frame(maxHeight: .infinity)
.padding()
}
private func infoText() -> some View {
HStack(spacing: 12) {
Image(systemName: "info.circle")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.primary)
.onTapGesture {
sheetItem = .showInfo
}
Text("Select network operators to use.")
}
}
@ViewBuilder private func operatorCheckView(_ serverOperator: ServerOperator) -> some View {
let checked = selectedOperatorIds.contains(serverOperator.operatorId)
let icon = checked ? "checkmark.circle.fill" : "circle"
let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
HStack(spacing: 10) {
Image(serverOperator.largeLogo(colorScheme))
.resizable()
.scaledToFit()
.frame(height: 48)
Spacer()
Image(systemName: icon)
.resizable()
.scaledToFit()
.frame(width: 26, height: 26)
.foregroundColor(iconColor)
}
.background(theme.colors.background)
.padding()
.clipShape(RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2)
)
.padding(.horizontal, 2)
.onTapGesture {
if checked {
selectedOperatorIds.remove(serverOperator.operatorId)
} else {
selectedOperatorIds.insert(serverOperator.operatorId)
}
}
}
private func reviewConditionsButton() -> some View {
ZStack {
Button {
reviewConditionsNavLinkActive = true
} label: {
Text("Review conditions")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
.disabled(selectedOperatorIds.isEmpty)
NavigationLink(isActive: $reviewConditionsNavLinkActive) {
reviewConditionsDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
private func setOperatorsButton() -> some View {
notificationsModeNavLinkButton {
Button {
Task {
if let enabledOperators = enabledOperators(serverOperators) {
let r = try await setServerOperators(operators: enabledOperators)
await MainActor.run {
ChatModel.shared.conditions = r
continueToNextStep()
}
} else {
await MainActor.run {
continueToNextStep()
}
}
}
} label: {
Text("Update")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
.disabled(selectedOperatorIds.isEmpty)
}
}
private func continueButton() -> some View {
notificationsModeNavLinkButton {
Button {
continueToNextStep()
} label: {
Text("Continue")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
.disabled(selectedOperatorIds.isEmpty)
}
}
private func reviewLaterButton() -> some View {
notificationsModeNavLinkButton {
Button {
continueToNextStep()
} label: {
Text("Review later")
}
.buttonStyle(.borderless)
}
}
private func continueToNextStep() {
if onboarding {
onboardingStageDefault.set(.step4_SetNotificationsMode)
notificationsModeNavLinkActive = true
} else {
dismiss()
}
}
func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View {
ZStack {
button()
NavigationLink(isActive: $notificationsModeNavLinkActive) {
notificationsModeDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
private func notificationsModeDestinationView() -> some View {
SetNotificationsMode()
.navigationTitle("Push notifications")
.navigationBarTitleDisplayMode(.large)
.navigationBarBackButtonHidden(true)
.modifier(ThemedBackground())
}
private func reviewConditionsDestinationView() -> some View {
reviewConditionsView()
.navigationTitle("Conditions of use")
.navigationBarTitleDisplayMode(.large)
.toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
.modifier(ThemedBackground(grouped: true))
}
@ViewBuilder private func reviewConditionsView() -> some View {
let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted }
let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
VStack(alignment: .leading, spacing: 20) {
if !operatorsWithConditionsAccepted.isEmpty {
Text("Conditions are already accepted for following operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
Text("Same conditions will apply to operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
} else {
Text("Conditions will be accepted for operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
}
ConditionsTextView()
acceptConditionsButton()
.padding(.bottom)
.padding(.bottom)
}
.padding(.horizontal)
.frame(maxHeight: .infinity)
}
private func acceptConditionsButton() -> some View {
notificationsModeNavLinkButton {
Button {
Task {
do {
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
let operatorIds = acceptForOperators.map { $0.operatorId }
let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)
await MainActor.run {
ChatModel.shared.conditions = r
}
if let enabledOperators = enabledOperators(r.serverOperators) {
let r2 = try await setServerOperators(operators: enabledOperators)
await MainActor.run {
ChatModel.shared.conditions = r2
continueToNextStep()
}
} else {
await MainActor.run {
continueToNextStep()
}
}
} catch let error {
await MainActor.run {
showAlert(
NSLocalizedString("Error accepting conditions", comment: "alert title"),
message: responseError(error)
)
}
}
}
} label: {
Text("Accept conditions")
}
.buttonStyle(OnboardingButtonStyle())
}
}
private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? {
var ops = operators
if !ops.isEmpty {
for i in 0..<ops.count {
var op = ops[i]
op.enabled = selectedOperatorIds.contains(op.operatorId)
ops[i] = op
}
let haveSMPStorage = ops.contains(where: { $0.enabled && $0.smpRoles.storage })
let haveSMPProxy = ops.contains(where: { $0.enabled && $0.smpRoles.proxy })
let haveXFTPStorage = ops.contains(where: { $0.enabled && $0.xftpRoles.storage })
let haveXFTPProxy = ops.contains(where: { $0.enabled && $0.xftpRoles.proxy })
if haveSMPStorage && haveSMPProxy && haveXFTPStorage && haveXFTPProxy {
return ops
} else if let firstEnabledIndex = ops.firstIndex(where: { $0.enabled }) {
var op = ops[firstEnabledIndex]
if !haveSMPStorage { op.smpRoles.storage = true }
if !haveSMPProxy { op.smpRoles.proxy = true }
if !haveXFTPStorage { op.xftpRoles.storage = true }
if !haveXFTPProxy { op.xftpRoles.proxy = true }
ops[firstEnabledIndex] = op
return ops
} else { // Shouldn't happen - view doesn't let to proceed if no operators are enabled
return nil
}
} else {
return nil
}
}
}
struct ChooseServerOperatorsInfoView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Network operators")
.font(.largeTitle)
.bold()
.padding(.vertical)
ScrollView {
VStack(alignment: .leading) {
Group {
Text("When more than one network operator is enabled, the app will use the servers of different operators for each conversation.")
Text("For example, if you receive messages via SimpleX Chat server, the app will use one of Flux servers for private routing.")
}
.padding(.bottom)
}
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.modifier(ThemedBackground())
}
}
#Preview {
ChooseServerOperators(onboarding: true)
}

View file

@ -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")
}
@ -78,6 +78,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,40 +115,47 @@ 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")
.font(.largeTitle)
.bold()
Text("Your profile, contacts and delivered messages are stored on your device.")
.foregroundColor(theme.colors.secondary)
Text("The profile is only shared with your contacts.")
.foregroundColor(theme.colors.secondary)
.padding(.bottom)
}
.padding(.bottom)
VStack(alignment: .leading, spacing: 20) {
Text("Your profile, contacts and delivered messages are stored on your device.")
.font(.callout)
.foregroundColor(theme.colors.secondary)
Text("The profile is only shared with your contacts.")
.font(.callout)
.foregroundColor(theme.colors.secondary)
ZStack(alignment: .topLeading) {
HStack {
let name = displayName.trimmingCharacters(in: .whitespaces)
let validName = mkValidName(name)
if name != validName {
Button {
showAlert(.invalidNameError(validName: validName))
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
ZStack {
if name != validName {
Button {
showAlert(.invalidNameError(validName: validName))
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
}
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
}
TextField("Enter your name…", text: $displayName)
.focused($focusDisplayName)
.padding(.leading, 32)
.padding(.horizontal)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(uiColor: .tertiarySystemFill))
)
}
.padding(.bottom)
.padding(.top)
Spacer()
onboardingButtons()
createProfileButton()
.padding(.bottom)
}
.onAppear() {
focusDisplayName = true
@ -129,88 +165,85 @@ struct CreateFirstProfile: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
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 {
ChooseServerOperators(onboarding: true)
.navigationTitle("Choose operators")
.navigationBarTitleDisplayMode(.large)
.navigationBarBackButtonHidden(true)
.modifier(ThemedBackground())
}
private func createProfile() {
hideKeyboard()
let profile = Profile(
displayName: displayName.trimmingCharacters(in: .whitespaces),
fullName: ""
)
let m = ChatModel.shared
do {
AppChatState.shared.set(.active)
m.currentUser = try apiCreateActiveUser(profile)
try startChat(onboarding: true)
onboardingStageDefault.set(.step3_ChooseServerOperators)
nextStepNavLinkActive = true
} catch let error {
showCreateProfileAlert(showAlert: showAlert, error)
}
}
}
private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) {
hideKeyboard()
let profile = Profile(
displayName: displayName.trimmingCharacters(in: .whitespaces),
fullName: ""
)
private func showCreateProfileAlert(
showAlert: (UserProfileAlert) -> Void,
_ error: Error
) {
let m = ChatModel.shared
do {
AppChatState.shared.set(.active)
m.currentUser = try apiCreateActiveUser(profile)
// .isEmpty check is redundant here, but it makes it clearer what is going on
if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) {
try startChat()
withAnimation {
onboardingStageDefault.set(.step3_CreateSimpleXAddress)
m.onboardingStage = .step3_CreateSimpleXAddress
}
switch error as? ChatResponse {
case .chatCmdError(_, .errorStore(.duplicateName)),
.chatCmdError(_, .error(.userExists)):
if m.currentUser == nil {
AlertManager.shared.showAlert(duplicateUserAlert)
} else {
onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete
dismiss()
m.users = try listUsers()
try getUserChatData()
showAlert(.duplicateUserError)
}
} catch let error {
switch error as? ChatResponse {
case .chatCmdError(_, .errorStore(.duplicateName)),
.chatCmdError(_, .error(.userExists)):
if m.currentUser == nil {
AlertManager.shared.showAlert(duplicateUserAlert)
} else {
showAlert(.duplicateUserError)
}
case .chatCmdError(_, .error(.invalidDisplayName)):
if m.currentUser == nil {
AlertManager.shared.showAlert(invalidDisplayNameAlert)
} else {
showAlert(.invalidDisplayNameError)
}
default:
let err: LocalizedStringKey = "Error: \(responseError(error))"
if m.currentUser == nil {
AlertManager.shared.showAlert(creatUserErrorAlert(err))
} else {
showAlert(.createUserError(error: err))
}
case .chatCmdError(_, .error(.invalidDisplayName)):
if m.currentUser == nil {
AlertManager.shared.showAlert(invalidDisplayNameAlert)
} else {
showAlert(.invalidDisplayNameError)
}
default:
let err: LocalizedStringKey = "Error: \(responseError(error))"
if m.currentUser == nil {
AlertManager.shared.showAlert(creatUserErrorAlert(err))
} else {
showAlert(.createUserError(error: err))
}
logger.error("Failed to create user or start chat: \(responseError(error))")
}
logger.error("Failed to create user or start chat: \(responseError(error))")
}
private func canCreateProfile(_ displayName: String) -> Bool {

View file

@ -9,13 +9,16 @@
import SwiftUI
struct HowItWorks: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var m: ChatModel
var onboarding: Bool
@Binding var createProfileNavLinkActive: Bool
var body: some View {
VStack(alignment: .leading) {
Text("How SimpleX works")
.font(.largeTitle)
.bold()
.padding(.vertical)
ScrollView {
VStack(alignment: .leading) {
@ -37,8 +40,8 @@ struct HowItWorks: View {
Spacer()
if onboarding {
OnboardingActionButton()
.padding(.bottom, 8)
createFirstProfileButton()
.padding(.bottom)
}
}
.lineLimit(10)
@ -46,10 +49,23 @@ struct HowItWorks: View {
.frame(maxHeight: .infinity, alignment: .top)
.modifier(ThemedBackground())
}
private func createFirstProfileButton() -> some View {
Button {
dismiss()
createProfileNavLinkActive = true
} label: {
Text("Create your profile")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
}
struct HowItWorks_Previews: PreviewProvider {
static var previews: some View {
HowItWorks(onboarding: true)
HowItWorks(
onboarding: true,
createProfileNavLinkActive: Binding.constant(false)
)
}
}

View file

@ -12,20 +12,39 @@ struct OnboardingView: View {
var onboarding: OnboardingStage
var body: some View {
switch onboarding {
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
case .step2_CreateProfile: CreateFirstProfile()
case .step3_CreateSimpleXAddress: CreateSimpleXAddress()
case .step4_SetNotificationsMode: SetNotificationsMode()
case .onboardingComplete: EmptyView()
NavigationView {
switch onboarding {
case .step1_SimpleXInfo:
SimpleXInfo(onboarding: true)
.modifier(ThemedBackground())
case .step2_CreateProfile: // deprecated
CreateFirstProfile()
.modifier(ThemedBackground())
case .step3_CreateSimpleXAddress: // deprecated
CreateSimpleXAddress()
case .step3_ChooseServerOperators:
ChooseServerOperators(onboarding: true)
.navigationTitle("Choose operators")
.navigationBarTitleDisplayMode(.large)
.navigationBarBackButtonHidden(true)
.modifier(ThemedBackground())
case .step4_SetNotificationsMode:
SetNotificationsMode()
.navigationTitle("Push notifications")
.navigationBarTitleDisplayMode(.large)
.navigationBarBackButtonHidden(true)
.modifier(ThemedBackground())
case .onboardingComplete: EmptyView()
}
}
}
}
enum OnboardingStage: String, Identifiable {
case step1_SimpleXInfo
case step2_CreateProfile
case step3_CreateSimpleXAddress
case step2_CreateProfile // deprecated
case step3_CreateSimpleXAddress // deprecated
case step3_ChooseServerOperators
case step4_SetNotificationsMode
case onboardingComplete

View file

@ -15,41 +15,39 @@ struct SetNotificationsMode: View {
@State private var showAlert: NotificationAlert?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Push notifications")
.font(.largeTitle)
.bold()
.frame(maxWidth: .infinity)
Text("Send notifications:")
ForEach(NotificationsMode.values) { mode in
NtfModeSelector(mode: mode, selection: $notificationMode)
}
Spacer()
Button {
if let token = m.deviceToken {
setNotificationsMode(token, notificationMode)
} else {
AlertManager.shared.showAlertMsg(title: "No device token!")
GeometryReader { g in
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Send notifications:")
ForEach(NotificationsMode.values) { mode in
NtfModeSelector(mode: mode, selection: $notificationMode)
}
onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete
} label: {
if case .off = notificationMode {
Text("Use chat")
} else {
Text("Enable notifications")
Spacer()
Button {
if let token = m.deviceToken {
setNotificationsMode(token, notificationMode)
} else {
AlertManager.shared.showAlertMsg(title: "No device token!")
}
onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete
} label: {
if case .off = notificationMode {
Text("Use chat")
} else {
Text("Enable notifications")
}
}
.buttonStyle(OnboardingButtonStyle())
.padding(.bottom)
}
.font(.title)
.frame(maxWidth: .infinity)
.padding()
.frame(minHeight: g.size.height)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
.frame(maxHeight: .infinity)
}
private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) {

View file

@ -13,12 +13,13 @@ struct SimpleXInfo: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme: ColorScheme
@State private var showHowItWorks = false
@State private var createProfileNavLinkActive = false
var onboarding: Bool
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 20) {
Image(colorScheme == .light ? "logo" : "logo-light")
.resizable()
.aspectRatio(contentMode: .fit)
@ -42,9 +43,9 @@ struct SimpleXInfo: View {
}
Spacer()
if onboarding {
OnboardingActionButton()
Spacer()
createFirstProfileButton()
Button {
m.migrationState = .pasteOrScanLink
@ -52,7 +53,6 @@ struct SimpleXInfo: View {
Label("Migrate from another device", systemImage: "tray.and.arrow.down")
.font(.subheadline)
}
.padding(.bottom, 8)
.frame(maxWidth: .infinity)
}
@ -62,9 +62,8 @@ struct SimpleXInfo: View {
Label("How it works", systemImage: "info.circle")
.font(.subheadline)
}
.padding(.bottom, 8)
.frame(maxWidth: .infinity)
.padding(.bottom)
}
.frame(minHeight: g.size.height)
}
@ -83,7 +82,10 @@ struct SimpleXInfo: View {
}
}
.sheet(isPresented: $showHowItWorks) {
HowItWorks(onboarding: onboarding)
HowItWorks(
onboarding: onboarding,
createProfileNavLinkActive: $createProfileNavLinkActive
)
}
}
.frame(maxHeight: .infinity)
@ -108,49 +110,31 @@ struct SimpleXInfo: View {
.padding(.bottom, 20)
.padding(.trailing, 6)
}
}
struct OnboardingActionButton: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
private func createFirstProfileButton() -> some View {
ZStack {
Button {
createProfileNavLinkActive = true
} label: {
Text("Create your profile")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
var body: some View {
if m.currentUser == nil {
actionButton("Create your profile", onboarding: .step2_CreateProfile)
} else {
actionButton("Make a private connection", onboarding: .onboardingComplete)
NavigationLink(isActive: $createProfileNavLinkActive) {
createProfileDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View {
Button {
withAnimation {
onboardingStageDefault.set(onboarding)
m.onboardingStage = onboarding
}
} label: {
HStack {
Text(label).font(.title2)
Image(systemName: "greaterthan")
}
}
.frame(maxWidth: .infinity)
.padding(.bottom)
}
private func actionButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
Button {
withAnimation {
action()
}
} label: {
HStack {
Text(label).font(.title2)
Image(systemName: "greaterthan")
}
}
.frame(maxWidth: .infinity)
.padding(.bottom)
private func createProfileDestinationView() -> some View {
CreateFirstProfile()
.navigationTitle("Create your profile")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground())
}
}

View file

@ -7,190 +7,209 @@
//
import SwiftUI
import SimpleXChat
private struct VersionDescription {
var version: String
var post: URL?
var features: [FeatureDescription]
var features: [Feature]
}
private struct FeatureDescription {
var icon: String?
var title: LocalizedStringKey
var description: LocalizedStringKey?
private enum Feature: Identifiable {
case feature(Description)
case view(FeatureView)
var id: LocalizedStringKey {
switch self {
case let .feature(d): d.title
case let .view(v): v.title
}
}
}
private struct Description {
let icon: String?
let title: LocalizedStringKey
let description: LocalizedStringKey?
var subfeatures: [(icon: String, description: LocalizedStringKey)] = []
}
private struct FeatureView {
let icon: String?
let title: LocalizedStringKey
let view: () -> any View
}
private let versionDescriptions: [VersionDescription] = [
VersionDescription(
version: "v4.2",
post: URL(string: "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "checkmark.shield",
title: "Security assessment",
description: "SimpleX Chat security was audited by Trail of Bits."
),
FeatureDescription(
)),
.feature(Description(
icon: "person.2",
title: "Group links",
description: "Admins can create the links to join groups."
),
FeatureDescription(
)),
.feature(Description(
icon: "checkmark",
title: "Auto-accept contact requests",
description: "With optional welcome message."
),
)),
]
),
VersionDescription(
version: "v4.3",
post: URL(string: "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "mic",
title: "Voice messages",
description: "Max 30 seconds, received instantly."
),
FeatureDescription(
)),
.feature(Description(
icon: "trash.slash",
title: "Irreversible message deletion",
description: "Your contacts can allow full message deletion."
),
FeatureDescription(
)),
.feature(Description(
icon: "externaldrive.connected.to.line.below",
title: "Improved server configuration",
description: "Add servers by scanning QR codes."
),
FeatureDescription(
)),
.feature(Description(
icon: "eye.slash",
title: "Improved privacy and security",
description: "Hide app screen in the recent apps."
),
)),
]
),
VersionDescription(
version: "v4.4",
post: URL(string: "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "stopwatch",
title: "Disappearing messages",
description: "Sent messages will be deleted after set time."
),
FeatureDescription(
)),
.feature(Description(
icon: "ellipsis.circle",
title: "Live messages",
description: "Recipients see updates as you type them."
),
FeatureDescription(
)),
.feature(Description(
icon: "checkmark.shield",
title: "Verify connection security",
description: "Compare security codes with your contacts."
),
FeatureDescription(
)),
.feature(Description(
icon: "camera",
title: "GIFs and stickers",
description: "Send them from gallery or custom keyboards."
),
FeatureDescription(
)),
.feature(Description(
icon: "character",
title: "French interface",
description: "Thanks to the users contribute via Weblate!"
)
)),
]
),
VersionDescription(
version: "v4.5",
post: URL(string: "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "person.crop.rectangle.stack",
title: "Multiple chat profiles",
description: "Different names, avatars and transport isolation."
),
FeatureDescription(
)),
.feature(Description(
icon: "rectangle.and.pencil.and.ellipsis",
title: "Message draft",
description: "Preserve the last message draft, with attachments."
),
FeatureDescription(
)),
.feature(Description(
icon: "network.badge.shield.half.filled",
title: "Transport isolation",
description: "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."
),
FeatureDescription(
)),
.feature(Description(
icon: "lock.doc",
title: "Private filenames",
description: "To protect timezone, image/voice files use UTC."
),
FeatureDescription(
)),
.feature(Description(
icon: "battery.25",
title: "Reduced battery usage",
description: "More improvements are coming soon!"
),
FeatureDescription(
)),
.feature(Description(
icon: "character",
title: "Italian interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
)
)),
]
),
VersionDescription(
version: "v4.6",
post: URL(string: "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "lock",
title: "Hidden chat profiles",
description: "Protect your chat profiles with a password!"
),
FeatureDescription(
)),
.feature(Description(
icon: "phone.arrow.up.right",
title: "Audio and video calls",
description: "Fully re-implemented - work in background!"
),
FeatureDescription(
)),
.feature(Description(
icon: "flag",
title: "Group moderation",
description: "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)"
),
FeatureDescription(
)),
.feature(Description(
icon: "plus.message",
title: "Group welcome message",
description: "Set the message shown to new members!"
),
FeatureDescription(
)),
.feature(Description(
icon: "battery.50",
title: "Further reduced battery usage",
description: "More improvements are coming soon!"
),
FeatureDescription(
)),
.feature(Description(
icon: "character",
title: "Chinese and Spanish interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
),
)),
]
),
VersionDescription(
version: "v5.0",
post: URL(string: "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "arrow.up.doc",
title: "Videos and files up to 1gb",
description: "Fast and no wait until the sender is online!"
),
FeatureDescription(
)),
.feature(Description(
icon: "lock",
title: "App passcode",
description: "Set it instead of system authentication."
),
FeatureDescription(
)),
.feature(Description(
icon: "character",
title: "Polish interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
),
)),
]
),
// Also
@ -200,240 +219,240 @@ private let versionDescriptions: [VersionDescription] = [
version: "v5.1",
post: URL(string: "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "face.smiling",
title: "Message reactions",
description: "Finally, we have them! 🚀"
),
FeatureDescription(
)),
.feature(Description(
icon: "arrow.up.message",
title: "Better messages",
description: "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history."
),
FeatureDescription(
)),
.feature(Description(
icon: "lock",
title: "Self-destruct passcode",
description: "All data is erased when it is entered."
),
FeatureDescription(
)),
.feature(Description(
icon: "character",
title: "Japanese interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
),
)),
]
),
VersionDescription(
version: "v5.2",
post: URL(string: "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "checkmark",
title: "Message delivery receipts!",
description: "The second tick we missed! ✅"
),
FeatureDescription(
)),
.feature(Description(
icon: "star",
title: "Find chats faster",
description: "Filter unread and favorite chats."
),
FeatureDescription(
)),
.feature(Description(
icon: "exclamationmark.arrow.triangle.2.circlepath",
title: "Keep your connections",
description: "Fix encryption after restoring backups."
),
FeatureDescription(
)),
.feature(Description(
icon: "stopwatch",
title: "Make one message disappear",
description: "Even when disabled in the conversation."
),
FeatureDescription(
)),
.feature(Description(
icon: "gift",
title: "A few more things",
description: "- more stable message delivery.\n- a bit better groups.\n- and more!"
),
)),
]
),
VersionDescription(
version: "v5.3",
post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "desktopcomputer",
title: "New desktop app!",
description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻"
),
FeatureDescription(
)),
.feature(Description(
icon: "lock",
title: "Encrypt stored files & media",
description: "App encrypts new local files (except videos)."
),
FeatureDescription(
)),
.feature(Description(
icon: "magnifyingglass",
title: "Discover and join groups",
description: "- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable."
),
FeatureDescription(
)),
.feature(Description(
icon: "theatermasks",
title: "Simplified incognito mode",
description: "Toggle incognito when connecting."
),
FeatureDescription(
)),
.feature(Description(
icon: "character",
title: "\(4) new interface languages",
description: "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
),
)),
]
),
VersionDescription(
version: "v5.4",
post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "desktopcomputer",
title: "Link mobile and desktop apps! 🔗",
description: "Via secure quantum resistant protocol."
),
FeatureDescription(
)),
.feature(Description(
icon: "person.2",
title: "Better groups",
description: "Faster joining and more reliable messages."
),
FeatureDescription(
)),
.feature(Description(
icon: "theatermasks",
title: "Incognito groups",
description: "Create a group using a random profile."
),
FeatureDescription(
)),
.feature(Description(
icon: "hand.raised",
title: "Block group members",
description: "To hide unwanted messages."
),
FeatureDescription(
)),
.feature(Description(
icon: "gift",
title: "A few more things",
description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!"
),
)),
]
),
VersionDescription(
version: "v5.5",
post: URL(string: "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "folder",
title: "Private notes",
description: "With encrypted files and media."
),
FeatureDescription(
)),
.feature(Description(
icon: "link",
title: "Paste link to connect!",
description: "Search bar accepts invitation links."
),
FeatureDescription(
)),
.feature(Description(
icon: "bubble.left.and.bubble.right",
title: "Join group conversations",
description: "Recent history and improved [directory bot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)."
),
FeatureDescription(
)),
.feature(Description(
icon: "battery.50",
title: "Improved message delivery",
description: "With reduced battery usage."
),
FeatureDescription(
)),
.feature(Description(
icon: "character",
title: "Turkish interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
),
)),
]
),
VersionDescription(
version: "v5.6",
post: URL(string: "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "key",
title: "Quantum resistant encryption",
description: "Enable in direct chats (BETA)!"
),
FeatureDescription(
)),
.feature(Description(
icon: "tray.and.arrow.up",
title: "App data migration",
description: "Migrate to another device via QR code."
),
FeatureDescription(
)),
.feature(Description(
icon: "phone",
title: "Picture-in-picture calls",
description: "Use the app while in the call."
),
FeatureDescription(
)),
.feature(Description(
icon: "hand.raised",
title: "Safer groups",
description: "Admins can block a member for all."
),
FeatureDescription(
)),
.feature(Description(
icon: "character",
title: "Hungarian interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
),
)),
]
),
VersionDescription(
version: "v5.7",
post: URL(string: "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "key",
title: "Quantum resistant encryption",
description: "Will be enabled in direct chats!"
),
FeatureDescription(
)),
.feature(Description(
icon: "arrowshape.turn.up.forward",
title: "Forward and save messages",
description: "Message source remains private."
),
FeatureDescription(
)),
.feature(Description(
icon: "music.note",
title: "In-call sounds",
description: "When connecting audio and video calls."
),
FeatureDescription(
)),
.feature(Description(
icon: "person.crop.square",
title: "Shape profile images",
description: "Square, circle, or anything in between."
),
FeatureDescription(
)),
.feature(Description(
icon: "antenna.radiowaves.left.and.right",
title: "Network management",
description: "More reliable network connection."
)
)),
]
),
VersionDescription(
version: "v5.8",
post: URL(string: "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "arrow.forward",
title: "Private message routing 🚀",
description: "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings."
),
FeatureDescription(
)),
.feature(Description(
icon: "network.badge.shield.half.filled",
title: "Safely receive files",
description: "Confirm files from unknown servers."
),
FeatureDescription(
)),
.feature(Description(
icon: "battery.50",
title: "Improved message delivery",
description: "With reduced battery usage."
)
)),
]
),
VersionDescription(
version: "v6.0",
post: URL(string: "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html"),
features: [
FeatureDescription(
.feature(Description(
icon: nil,
title: "New chat experience 🎉",
description: nil,
@ -444,8 +463,8 @@ private let versionDescriptions: [VersionDescription] = [
("platter.filled.bottom.and.arrow.down.iphone", "Use the app with one hand."),
("paintpalette", "Color chats with the new themes."),
]
),
FeatureDescription(
)),
.feature(Description(
icon: nil,
title: "New media options",
description: nil,
@ -454,39 +473,39 @@ private let versionDescriptions: [VersionDescription] = [
("play.circle", "Play from the chat list."),
("circle.filled.pattern.diagonalline.rectangle", "Blur for better privacy.")
]
),
FeatureDescription(
)),
.feature(Description(
icon: "arrow.forward",
title: "Private message routing 🚀",
description: "It protects your IP address and connections."
),
FeatureDescription(
)),
.feature(Description(
icon: "network",
title: "Better networking",
description: "Connection and servers status."
)
)),
]
),
VersionDescription(
version: "v6.1",
post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"),
features: [
FeatureDescription(
.feature(Description(
icon: "checkmark.shield",
title: "Better security ✅",
description: "SimpleX protocols reviewed by Trail of Bits."
),
FeatureDescription(
)),
.feature(Description(
icon: "video",
title: "Better calls",
description: "Switch audio and video during the call."
),
FeatureDescription(
)),
.feature(Description(
icon: "bolt",
title: "Better notifications",
description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!"
),
FeatureDescription(
)),
.feature(Description(
icon: nil,
title: "Better user experience",
description: nil,
@ -497,9 +516,25 @@ private let versionDescriptions: [VersionDescription] = [
("arrowshape.turn.up.right", "Forward up to 20 messages at once."),
("flag", "Delete or moderate up to 200 messages.")
]
),
)),
]
),
VersionDescription(
version: "v6.2 (beta.1)",
post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"),
features: [
.view(FeatureView(
icon: nil,
title: "Network decentralization",
view: { NewOperatorsView() }
)),
.feature(Description(
icon: "text.quote",
title: "Improved chat navigation",
description: "- Open chat on the first unread message.\n- Jump to quoted messages."
)),
]
)
]
private let lastVersion = versionDescriptions.last!.version
@ -514,14 +549,68 @@ func shouldShowWhatsNew() -> Bool {
return v != lastVersion
}
fileprivate struct NewOperatorsView: View {
@State private var showOperatorsSheet = false
var body: some View {
VStack(alignment: .leading) {
Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo)
.resizable()
.scaledToFit()
.frame(height: 48)
Text("The second preset operator in the app!")
.multilineTextAlignment(.leading)
.lineLimit(10)
HStack {
Button("Enable Flux") {
showOperatorsSheet = true
}
Text("for better metadata privacy.")
}
}
.sheet(isPresented: $showOperatorsSheet) {
NavigationView {
ChooseServerOperators(onboarding: false)
.modifier(ThemedBackground())
}
}
}
}
private enum WhatsNewViewSheet: Identifiable {
case showConditions
var id: String {
switch self {
case .showConditions: return "showConditions"
}
}
}
struct WhatsNewView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@State var currentVersion = versionDescriptions.count - 1
@State var currentVersionNav = versionDescriptions.count - 1
var viaSettings = false
var updatedConditions: Bool
@State private var sheetItem: WhatsNewViewSheet? = nil
var body: some View {
whatsNewView()
.sheet(item: $sheetItem) { item in
switch item {
case .showConditions:
UsageConditionsView(
currUserServers: Binding.constant([]),
userServers: Binding.constant([])
)
.modifier(ThemedBackground(grouped: true))
}
}
}
private func whatsNewView() -> some View {
VStack {
TabView(selection: $currentVersion) {
ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in
@ -532,9 +621,11 @@ struct WhatsNewView: View {
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical)
ForEach(v.features, id: \.title) { f in
featureDescription(f)
.padding(.bottom, 8)
ForEach(v.features) { f in
switch f {
case let .feature(d): featureDescription(d).padding(.bottom, 8)
case let .view(v): AnyView(v.view()).padding(.bottom, 8)
}
}
if let post = v.post {
Link(destination: post) {
@ -544,13 +635,20 @@ struct WhatsNewView: View {
}
}
}
if updatedConditions {
Button("View updated conditions") {
sheetItem = .showConditions
}
}
if !viaSettings {
Spacer()
Button("Ok") {
dismiss()
}
.font(.title3)
.frame(maxWidth: .infinity, alignment: .center)
Spacer()
}
}
@ -568,20 +666,24 @@ struct WhatsNewView: View {
currentVersionNav = currentVersion
}
}
private func featureDescription(_ f: FeatureDescription) -> some View {
VStack(alignment: .leading, spacing: 4) {
if let icon = f.icon {
HStack(alignment: .center, spacing: 4) {
Image(systemName: icon)
.symbolRenderingMode(.monochrome)
.foregroundColor(theme.colors.secondary)
.frame(minWidth: 30, alignment: .center)
Text(f.title).font(.title3).bold()
}
} else {
Text(f.title).font(.title3).bold()
@ViewBuilder private func featureHeader(_ icon: String?, _ title: LocalizedStringKey) -> some View {
if let icon {
HStack(alignment: .center, spacing: 4) {
Image(systemName: icon)
.symbolRenderingMode(.monochrome)
.foregroundColor(theme.colors.secondary)
.frame(minWidth: 30, alignment: .center)
Text(title).font(.title3).bold()
}
} else {
Text(title).font(.title3).bold()
}
}
private func featureDescription(_ f: Description) -> some View {
VStack(alignment: .leading, spacing: 4) {
featureHeader(f.icon, f.title)
if let d = f.description {
Text(d)
.multilineTextAlignment(.leading)
@ -636,6 +738,6 @@ struct WhatsNewView: View {
struct NewFeaturesView_Previews: PreviewProvider {
static var previews: some View {
WhatsNewView()
WhatsNewView(updatedConditions: false)
}
}

View file

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

View file

@ -0,0 +1,434 @@
//
// NetworkServersView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 02/08/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
private enum NetworkAlert: Identifiable {
case error(err: String)
var id: String {
switch self {
case let .error(err): return "error \(err)"
}
}
}
private enum NetworkAndServersSheet: Identifiable {
case showConditions
var id: String {
switch self {
case .showConditions: return "showConditions"
}
}
}
struct NetworkAndServers: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme: ColorScheme
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var ss: SaveableSettings
@State private var sheetItem: NetworkAndServersSheet? = nil
@State private var justOpened = true
@State private var showSaveDialog = false
var body: some View {
VStack {
List {
let conditionsAction = m.conditions.conditionsAction
let anyOperatorEnabled = ss.servers.userServers.contains(where: { $0.operator?.enabled ?? false })
Section {
ForEach(ss.servers.userServers.enumerated().map { $0 }, id: \.element.id) { idx, userOperatorServers in
if let serverOperator = userOperatorServers.operator {
serverOperatorView(idx, serverOperator)
} else {
EmptyView()
}
}
if let conditionsAction = conditionsAction, anyOperatorEnabled {
conditionsButton(conditionsAction)
}
} header: {
Text("Preset servers")
.foregroundColor(theme.colors.secondary)
} footer: {
switch conditionsAction {
case let .review(_, deadline, _):
if let deadline = deadline, anyOperatorEnabled {
Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).")
.foregroundColor(theme.colors.secondary)
}
default:
EmptyView()
}
}
Section {
if let idx = ss.servers.userServers.firstIndex(where: { $0.operator == nil }) {
NavigationLink {
YourServersView(
userServers: $ss.servers.userServers,
serverErrors: $ss.servers.serverErrors,
operatorIndex: idx
)
.navigationTitle("Your servers")
.modifier(ThemedBackground(grouped: true))
} label: {
HStack {
Text("Your servers")
if ss.servers.userServers[idx] != ss.servers.currUserServers[idx] {
Spacer()
unsavedChangesIndicator()
}
}
}
}
NavigationLink {
AdvancedNetworkSettings()
.navigationTitle("Advanced settings")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Advanced network settings")
}
} header: {
Text("Messages & files")
.foregroundColor(theme.colors.secondary)
}
Section {
Button("Save servers", action: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) })
.disabled(!serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors))
} footer: {
if let errStr = globalServersError(ss.servers.serverErrors) {
ServersErrorView(errStr: errStr)
} else if !ss.servers.serverErrors.isEmpty {
ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error"))
}
}
Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
NavigationLink {
RTCServers()
.navigationTitle("Your ICE servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("WebRTC ICE servers")
}
}
Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) {
HStack {
Text(m.networkInfo.networkType.text)
Spacer()
Image(systemName: "circle.fill").foregroundColor(m.networkInfo.online ? .green : .red)
}
}
}
}
.task {
// this condition is needed to prevent re-setting the servers when exiting single server view
if justOpened {
do {
ss.servers.currUserServers = try await getUserServers()
ss.servers.userServers = ss.servers.currUserServers
ss.servers.serverErrors = []
} catch let error {
await MainActor.run {
showAlert(
NSLocalizedString("Error loading servers", comment: "alert title"),
message: responseError(error)
)
}
}
justOpened = false
}
}
.modifier(BackButton(disabled: Binding.constant(false)) {
if serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors) {
showSaveDialog = true
} else {
dismiss()
}
})
.confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) {
Button("Save") {
saveServers($ss.servers.currUserServers, $ss.servers.userServers)
dismiss()
}
Button("Exit without saving") { dismiss() }
}
.sheet(item: $sheetItem) { item in
switch item {
case .showConditions:
UsageConditionsView(
currUserServers: $ss.servers.currUserServers,
userServers: $ss.servers.userServers
)
.modifier(ThemedBackground(grouped: true))
}
}
}
private func serverOperatorView(_ operatorIndex: Int, _ serverOperator: ServerOperator) -> some View {
NavigationLink() {
OperatorView(
currUserServers: $ss.servers.currUserServers,
userServers: $ss.servers.userServers,
serverErrors: $ss.servers.serverErrors,
operatorIndex: operatorIndex,
useOperator: serverOperator.enabled
)
.navigationBarTitle("\(serverOperator.tradeName) servers")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
HStack {
Image(serverOperator.logo(colorScheme))
.resizable()
.scaledToFit()
.grayscale(serverOperator.enabled ? 0.0 : 1.0)
.frame(width: 24, height: 24)
Text(serverOperator.tradeName)
.foregroundColor(serverOperator.enabled ? theme.colors.onBackground : theme.colors.secondary)
if ss.servers.userServers[operatorIndex] != ss.servers.currUserServers[operatorIndex] {
Spacer()
unsavedChangesIndicator()
}
}
}
}
private func unsavedChangesIndicator() -> some View {
Image(systemName: "pencil")
.foregroundColor(theme.colors.secondary)
.symbolRenderingMode(.monochrome)
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
}
private func conditionsButton(_ conditionsAction: UsageConditionsAction) -> some View {
Button {
sheetItem = .showConditions
} label: {
switch conditionsAction {
case .review:
Text("Review conditions")
case .accepted:
Text("Accepted conditions")
}
}
}
}
struct UsageConditionsView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var currUserServers: [UserOperatorServers]
@Binding var userServers: [UserOperatorServers]
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Text("Conditions of use").font(.largeTitle).bold()
Spacer()
conditionsLinkButton()
}
.padding(.top)
.padding(.top)
switch ChatModel.shared.conditions.conditionsAction {
case .none:
ConditionsTextView()
.padding(.bottom)
.padding(.bottom)
case let .review(operators, deadline, _):
Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
ConditionsTextView()
VStack(spacing: 8) {
acceptConditionsButton(operators.map { $0.operatorId })
if let deadline = deadline {
Text("Conditions will be automatically accepted for enabled operators on: \(conditionsTimestamp(deadline)).")
.foregroundColor(theme.colors.secondary)
.font(.footnote)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 32)
}
}
.padding(.bottom)
.padding(.bottom)
case let .accepted(operators):
Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
ConditionsTextView()
.padding(.bottom)
.padding(.bottom)
}
}
.padding(.horizontal)
.frame(maxHeight: .infinity)
}
private func acceptConditionsButton(_ operatorIds: [Int64]) -> some View {
Button {
acceptForOperators(operatorIds)
} label: {
Text("Accept conditions")
}
.buttonStyle(OnboardingButtonStyle())
}
func acceptForOperators(_ operatorIds: [Int64]) {
Task {
do {
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)
await MainActor.run {
ChatModel.shared.conditions = r
updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators)
updateOperatorsConditionsAcceptance($userServers, r.serverOperators)
dismiss()
}
} catch let error {
await MainActor.run {
showAlert(
NSLocalizedString("Error accepting conditions", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
}
func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) {
let userServersToValidate = userServers.wrappedValue
Task {
do {
let errs = try await validateServers(userServers: userServersToValidate)
await MainActor.run {
serverErrors.wrappedValue = errs
}
} catch let error {
logger.error("validateServers error: \(responseError(error))")
}
}
}
func serversCanBeSaved(
_ currUserServers: [UserOperatorServers],
_ userServers: [UserOperatorServers],
_ serverErrors: [UserServersError]
) -> Bool {
return userServers != currUserServers && serverErrors.isEmpty
}
struct ServersErrorView: View {
@EnvironmentObject var theme: AppTheme
var errStr: String
var body: some View {
HStack {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
Text(errStr)
.foregroundColor(theme.colors.secondary)
}
}
}
func globalServersError(_ serverErrors: [UserServersError]) -> String? {
for err in serverErrors {
if let errStr = err.globalError {
return errStr
}
}
return nil
}
func globalSMPServersError(_ serverErrors: [UserServersError]) -> String? {
for err in serverErrors {
if let errStr = err.globalSMPError {
return errStr
}
}
return nil
}
func globalXFTPServersError(_ serverErrors: [UserServersError]) -> String? {
for err in serverErrors {
if let errStr = err.globalXFTPError {
return errStr
}
}
return nil
}
func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set<String> {
let duplicateHostsList = serverErrors.compactMap { err in
if case let .duplicateServer(_, _, duplicateHost) = err {
return duplicateHost
} else {
return nil
}
}
return Set(duplicateHostsList)
}
func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServers: Binding<[UserOperatorServers]>) {
let userServersToSave = userServers.wrappedValue
Task {
do {
try await setUserServers(userServers: userServersToSave)
// Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers)
do {
let updatedServers = try await getUserServers()
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..<usvs.wrappedValue.count {
if let updatedOperator = updatedOperators.first(where: { $0.operatorId == usvs.wrappedValue[i].operator?.operatorId }) {
usvs.wrappedValue[i].operator?.conditionsAcceptance = updatedOperator.conditionsAcceptance
}
}
}
struct NetworkServersView_Previews: PreviewProvider {
static var previews: some View {
NetworkAndServers()
}
}

View file

@ -0,0 +1,156 @@
//
// NewServerView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 13.11.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct NewServerView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@State private var serverToEdit: UserServer = .empty
@State private var showTestFailure = false
@State private var testing = false
@State private var testFailure: ProtocolTestFailure?
var body: some View {
ZStack {
customServer()
if testing {
ProgressView().scaleEffect(2)
}
}
.modifier(BackButton(disabled: Binding.constant(false)) {
addServer(serverToEdit, $userServers, $serverErrors, dismiss)
})
.alert(isPresented: $showTestFailure) {
Alert(
title: Text("Server test failed!"),
message: Text(testFailure?.localizedDescription ?? "")
)
}
}
// TODO Possibly refactor - similar functions in ProtocolServerView
private func customServer() -> some View {
VStack {
let serverAddress = parseServerAddress(serverToEdit.server)
let valid = serverAddress?.valid == true
List {
Section {
TextEditor(text: $serverToEdit.server)
.multilineTextAlignment(.leading)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.allowsTightening(true)
.lineLimit(10)
.frame(height: 144)
.padding(-6)
} header: {
HStack {
Text("Your server address")
.foregroundColor(theme.colors.secondary)
if !valid {
Spacer()
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
}
}
useServerSection(valid)
if valid {
Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) {
MutableQRCode(uri: $serverToEdit.server)
.listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
}
}
}
}
}
private func useServerSection(_ valid: Bool) -> some View {
Section(header: Text("Use server").foregroundColor(theme.colors.secondary)) {
HStack {
Button("Test server") {
testing = true
serverToEdit.tested = nil
Task {
if let f = await testServerConnection(server: $serverToEdit) {
showTestFailure = true
testFailure = f
}
await MainActor.run { testing = false }
}
}
.disabled(!valid || testing)
Spacer()
showTestStatus(server: serverToEdit)
}
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
}
}
}
func serverProtocolAndOperator(_ server: UserServer, _ userServers: [UserOperatorServers]) -> (ServerProtocol, ServerOperator?)? {
if let serverAddress = parseServerAddress(server.server) {
let serverProtocol = serverAddress.serverProtocol
let hostnames = serverAddress.hostnames
let matchingOperator = userServers.compactMap { $0.operator }.first { op in
op.serverDomains.contains { domain in
hostnames.contains { hostname in
hostname.hasSuffix(domain)
}
}
}
return (serverProtocol, matchingOperator)
} else {
return nil
}
}
func addServer(
_ server: UserServer,
_ userServers: Binding<[UserOperatorServers]>,
_ serverErrors: Binding<[UserServersError]>,
_ dismiss: DismissAction
) {
if let (serverProtocol, matchingOperator) = serverProtocolAndOperator(server, userServers.wrappedValue) {
if let i = userServers.wrappedValue.firstIndex(where: { $0.operator?.operatorId == matchingOperator?.operatorId }) {
switch serverProtocol {
case .smp: userServers[i].wrappedValue.smpServers.append(server)
case .xftp: userServers[i].wrappedValue.xftpServers.append(server)
}
validateServers_(userServers, serverErrors)
dismiss()
if let op = matchingOperator {
showAlert(
NSLocalizedString("Operator server", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("Server added to operator %@.", comment: "alert message"), op.tradeName)
)
}
} else { // Shouldn't happen
dismiss()
showAlert(NSLocalizedString("Error adding server", comment: "alert title"))
}
} else {
dismiss()
if server.server.trimmingCharacters(in: .whitespaces) != "" {
showAlert(
NSLocalizedString("Invalid server address!", comment: "alert title"),
message: NSLocalizedString("Check server address and try again.", comment: "alert title")
)
}
}
}
#Preview {
NewServerView(
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([])
)
}

View file

@ -0,0 +1,585 @@
//
// OperatorView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.10.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct OperatorView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Environment(\.colorScheme) var colorScheme: ColorScheme
@EnvironmentObject var theme: AppTheme
@Environment(\.editMode) private var editMode
@Binding var currUserServers: [UserOperatorServers]
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
var operatorIndex: Int
@State var useOperator: Bool
@State private var useOperatorToggleReset: Bool = false
@State private var showConditionsSheet: Bool = false
@State private var selectedServer: String? = nil
@State private var testing = false
var body: some View {
operatorView()
.opacity(testing ? 0.4 : 1)
.overlay {
if testing {
ProgressView()
.scaleEffect(2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.allowsHitTesting(!testing)
}
@ViewBuilder private func operatorView() -> some View {
let duplicateHosts = findDuplicateHosts(serverErrors)
VStack {
List {
Section {
infoViewLink()
useOperatorToggle()
} header: {
Text("Operator")
.foregroundColor(theme.colors.secondary)
} footer: {
if let errStr = globalServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else {
switch (userServers[operatorIndex].operator_.conditionsAcceptance) {
case let .accepted(acceptedAt):
if let acceptedAt = acceptedAt {
Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).")
.foregroundColor(theme.colors.secondary)
}
case let .required(deadline):
if userServers[operatorIndex].operator_.enabled, let deadline = deadline {
Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).")
.foregroundColor(theme.colors.secondary)
}
}
}
}
if userServers[operatorIndex].operator_.enabled {
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
Section {
Toggle("To receive", isOn: $userServers[operatorIndex].operator_.smpRoles.storage)
.onChange(of: userServers[operatorIndex].operator_.smpRoles.storage) { _ in
validateServers_($userServers, $serverErrors)
}
Toggle("For private routing", isOn: $userServers[operatorIndex].operator_.smpRoles.proxy)
.onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in
validateServers_($userServers, $serverErrors)
}
} header: {
Text("Use for messages")
.foregroundColor(theme.colors.secondary)
} footer: {
if let errStr = globalSMPServersError(serverErrors) {
ServersErrorView(errStr: errStr)
}
}
}
// Preset servers can't be deleted
if !userServers[operatorIndex].smpServers.filter({ $0.preset }).isEmpty {
Section {
ForEach($userServers[operatorIndex].smpServers) { srv in
if srv.wrappedValue.preset {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .smp,
backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
selectedServer: $selectedServer
)
} else {
EmptyView()
}
}
} header: {
Text("Message servers")
.foregroundColor(theme.colors.secondary)
} footer: {
if let errStr = globalSMPServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else {
Text("The servers for new connections of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
.foregroundColor(theme.colors.secondary)
.lineLimit(10)
}
}
}
if !userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty {
Section {
ForEach($userServers[operatorIndex].smpServers) { srv in
if !srv.wrappedValue.preset && !srv.wrappedValue.deleted {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .smp,
backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
selectedServer: $selectedServer
)
} else {
EmptyView()
}
}
.onDelete { indexSet in
deleteSMPServer($userServers, operatorIndex, indexSet)
validateServers_($userServers, $serverErrors)
}
} header: {
Text("Added message servers")
.foregroundColor(theme.colors.secondary)
}
}
if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty {
Section {
Toggle("To send", isOn: $userServers[operatorIndex].operator_.xftpRoles.storage)
.onChange(of: userServers[operatorIndex].operator_.xftpRoles.storage) { _ in
validateServers_($userServers, $serverErrors)
}
} header: {
Text("Use for files")
.foregroundColor(theme.colors.secondary)
} footer: {
if let errStr = globalXFTPServersError(serverErrors) {
ServersErrorView(errStr: errStr)
}
}
}
// Preset servers can't be deleted
if !userServers[operatorIndex].xftpServers.filter({ $0.preset }).isEmpty {
Section {
ForEach($userServers[operatorIndex].xftpServers) { srv in
if srv.wrappedValue.preset {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .xftp,
backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
selectedServer: $selectedServer
)
} else {
EmptyView()
}
}
} header: {
Text("Media & file servers")
.foregroundColor(theme.colors.secondary)
} footer: {
if let errStr = globalXFTPServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else {
Text("The servers for new files of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
.foregroundColor(theme.colors.secondary)
.lineLimit(10)
}
}
}
if !userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty {
Section {
ForEach($userServers[operatorIndex].xftpServers) { srv in
if !srv.wrappedValue.preset && !srv.wrappedValue.deleted {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .xftp,
backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
selectedServer: $selectedServer
)
} else {
EmptyView()
}
}
.onDelete { indexSet in
deleteXFTPServer($userServers, operatorIndex, indexSet)
validateServers_($userServers, $serverErrors)
}
} header: {
Text("Added media & file servers")
.foregroundColor(theme.colors.secondary)
}
}
Section {
TestServersButton(
smpServers: $userServers[operatorIndex].smpServers,
xftpServers: $userServers[operatorIndex].xftpServers,
testing: $testing
)
}
}
}
}
.toolbar {
if (
!userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty ||
!userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty
) {
EditButton()
}
}
.sheet(isPresented: $showConditionsSheet, onDismiss: onUseToggleSheetDismissed) {
SingleOperatorUsageConditionsView(
currUserServers: $currUserServers,
userServers: $userServers,
serverErrors: $serverErrors,
operatorIndex: operatorIndex
)
.modifier(ThemedBackground(grouped: true))
}
}
private func infoViewLink() -> some View {
NavigationLink() {
OperatorInfoView(serverOperator: userServers[operatorIndex].operator_)
.navigationBarTitle("Network operator")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
Image(userServers[operatorIndex].operator_.largeLogo(colorScheme))
.resizable()
.scaledToFit()
.grayscale(userServers[operatorIndex].operator_.enabled ? 0.0 : 1.0)
.frame(height: 40)
}
}
private func useOperatorToggle() -> some View {
Toggle("Use servers", isOn: $useOperator)
.onChange(of: useOperator) { useOperatorToggle in
if useOperatorToggleReset {
useOperatorToggleReset = false
} else if useOperatorToggle {
switch userServers[operatorIndex].operator_.conditionsAcceptance {
case .accepted:
userServers[operatorIndex].operator_.enabled = true
validateServers_($userServers, $serverErrors)
case let .required(deadline):
if deadline == nil {
showConditionsSheet = true
} else {
userServers[operatorIndex].operator_.enabled = true
validateServers_($userServers, $serverErrors)
}
}
} else {
userServers[operatorIndex].operator_.enabled = false
validateServers_($userServers, $serverErrors)
}
}
}
private func onUseToggleSheetDismissed() {
if useOperator && !userServers[operatorIndex].operator_.conditionsAcceptance.usageAllowed {
useOperatorToggleReset = true
useOperator = false
}
}
}
func conditionsTimestamp(_ date: Date) -> String {
let localDateFormatter = DateFormatter()
localDateFormatter.dateStyle = .medium
localDateFormatter.timeStyle = .none
return localDateFormatter.string(from: date)
}
struct OperatorInfoView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme: ColorScheme
var serverOperator: ServerOperator
var body: some View {
VStack {
List {
Section {
VStack(alignment: .leading) {
Image(serverOperator.largeLogo(colorScheme))
.resizable()
.scaledToFit()
.frame(height: 48)
if let legalName = serverOperator.legalName {
Text(legalName)
}
}
}
Section {
VStack(alignment: .leading, spacing: 12) {
ForEach(serverOperator.info.description, id: \.self) { d in
Text(d)
}
}
}
Section {
Link("\(serverOperator.info.website)", destination: URL(string: serverOperator.info.website)!)
}
}
}
}
}
struct ConditionsTextView: View {
@State private var conditionsData: (UsageConditions, String?, UsageConditions?)?
@State private var failedToLoad: Bool = false
let defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md"
var body: some View {
viewBody()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task {
do {
conditionsData = try await getUsageConditions()
} catch let error {
logger.error("ConditionsTextView getUsageConditions error: \(responseError(error))")
failedToLoad = true
}
}
}
// TODO Markdown & diff rendering
@ViewBuilder private func viewBody() -> some View {
if let (usageConditions, conditionsText, acceptedConditions) = conditionsData {
if let conditionsText = conditionsText {
ScrollView {
Text(conditionsText.trimmingCharacters(in: .whitespacesAndNewlines))
.padding()
}
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
} else {
let conditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/\(usageConditions.conditionsCommit)/PRIVACY.md"
conditionsLinkView(conditionsLink)
}
} else if failedToLoad {
conditionsLinkView(defaultConditionsLink)
} else {
ProgressView()
.scaleEffect(2)
}
}
private func conditionsLinkView(_ conditionsLink: String) -> some View {
VStack(alignment: .leading, spacing: 20) {
Text("Current conditions text couldn't be loaded, you can review conditions via this link:")
Link(destination: URL(string: conditionsLink)!) {
Text(conditionsLink)
.multilineTextAlignment(.leading)
}
}
}
}
struct SingleOperatorUsageConditionsView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var currUserServers: [UserOperatorServers]
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
var operatorIndex: Int
@State private var usageConditionsNavLinkActive: Bool = false
var body: some View {
viewBody()
}
@ViewBuilder private func viewBody() -> some View {
let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted }
if case .accepted = userServers[operatorIndex].operator_.conditionsAcceptance {
// In current UI implementation this branch doesn't get shown - as conditions can't be opened from inside operator once accepted
VStack(alignment: .leading, spacing: 20) {
Group {
viewHeader()
ConditionsTextView()
.padding(.bottom)
.padding(.bottom)
}
.padding(.horizontal)
}
.frame(maxHeight: .infinity)
} else if !operatorsWithConditionsAccepted.isEmpty {
NavigationView {
VStack(alignment: .leading, spacing: 20) {
Group {
viewHeader()
Text("Conditions are already accepted for following operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
Text("Same conditions will apply to operator **\(userServers[operatorIndex].operator_.legalName_)**.")
conditionsAppliedToOtherOperatorsText()
usageConditionsNavLinkButton()
Spacer()
acceptConditionsButton()
.padding(.bottom)
.padding(.bottom)
}
.padding(.horizontal)
}
.frame(maxHeight: .infinity)
}
} else {
VStack(alignment: .leading, spacing: 20) {
Group {
viewHeader()
Text("To use the servers of **\(userServers[operatorIndex].operator_.legalName_)**, accept conditions of use.")
conditionsAppliedToOtherOperatorsText()
ConditionsTextView()
acceptConditionsButton()
.padding(.bottom)
.padding(.bottom)
}
.padding(.horizontal)
}
.frame(maxHeight: .infinity)
}
}
private func viewHeader() -> some View {
HStack {
Text("Use \(userServers[operatorIndex].operator_.tradeName)").font(.largeTitle).bold()
Spacer()
conditionsLinkButton()
}
.padding(.top)
.padding(.top)
}
@ViewBuilder private func conditionsAppliedToOtherOperatorsText() -> some View {
let otherOperatorsToApply = ChatModel.shared.conditions.serverOperators.filter {
$0.enabled &&
!$0.conditionsAcceptance.conditionsAccepted &&
$0.operatorId != userServers[operatorIndex].operator_.operatorId
}
if !otherOperatorsToApply.isEmpty {
Text("These conditions will also apply for: **\(otherOperatorsToApply.map { $0.legalName_ }.joined(separator: ", "))**.")
}
}
@ViewBuilder private func acceptConditionsButton() -> some View {
let operatorIds = ChatModel.shared.conditions.serverOperators
.filter {
$0.operatorId == userServers[operatorIndex].operator_.operatorId || // Opened operator
($0.enabled && !$0.conditionsAcceptance.conditionsAccepted) // Other enabled operators with conditions not accepted
}
.map { $0.operatorId }
Button {
acceptForOperators(operatorIds, operatorIndex)
} label: {
Text("Accept conditions")
}
.buttonStyle(OnboardingButtonStyle())
}
func acceptForOperators(_ operatorIds: [Int64], _ operatorIndexToEnable: Int) {
Task {
do {
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)
await MainActor.run {
ChatModel.shared.conditions = r
updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators)
updateOperatorsConditionsAcceptance($userServers, r.serverOperators)
userServers[operatorIndexToEnable].operator?.enabled = true
validateServers_($userServers, $serverErrors)
dismiss()
}
} catch let error {
await MainActor.run {
showAlert(
NSLocalizedString("Error accepting conditions", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
private func usageConditionsNavLinkButton() -> some View {
ZStack {
Button {
usageConditionsNavLinkActive = true
} label: {
Text("View conditions")
}
NavigationLink(isActive: $usageConditionsNavLinkActive) {
usageConditionsDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
private func usageConditionsDestinationView() -> some View {
ConditionsTextView()
.padding()
.padding(.bottom)
.navigationTitle("Conditions of use")
.navigationBarTitleDisplayMode(.large)
.toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
.modifier(ThemedBackground(grouped: true))
}
}
func conditionsLinkButton() -> some View {
let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit
let mdUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/\(commit)/PRIVACY.md") ?? conditionsURL
return Menu {
Link(destination: mdUrl) {
Label("Open conditions", systemImage: "doc")
}
if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") {
Link(destination: commitUrl) {
Label("Open changes", systemImage: "ellipsis")
}
}
} label: {
Image(systemName: "arrow.up.right.circle")
.resizable()
.scaledToFit()
.frame(width: 20)
.padding(2)
.contentShape(Circle())
}
}
#Preview {
OperatorView(
currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([]),
operatorIndex: 1,
useOperator: ServerOperator.sampleData1.enabled
)
}

View file

@ -12,15 +12,15 @@ import SimpleXChat
struct ProtocolServerView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
let serverProtocol: ServerProtocol
@Binding var server: ServerCfg
@State var serverToEdit: ServerCfg
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
@Binding var server: UserServer
@State var serverToEdit: UserServer
var backLabel: LocalizedStringKey
@State private var showTestFailure = false
@State private var testing = false
@State private var testFailure: ProtocolTestFailure?
var proto: String { serverProtocol.rawValue.uppercased() }
var body: some View {
ZStack {
if server.preset {
@ -32,9 +32,33 @@ struct ProtocolServerView: View {
ProgressView().scaleEffect(2)
}
}
.modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) {
server = serverToEdit
dismiss()
.modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) {
if let (serverToEditProtocol, serverToEditOperator) = serverProtocolAndOperator(serverToEdit, userServers),
let (serverProtocol, serverOperator) = serverProtocolAndOperator(server, userServers) {
if serverToEditProtocol != serverProtocol {
dismiss()
showAlert(
NSLocalizedString("Error updating server", comment: "alert title"),
message: NSLocalizedString("Server protocol changed.", comment: "alert title")
)
} else if serverToEditOperator != serverOperator {
dismiss()
showAlert(
NSLocalizedString("Error updating server", comment: "alert title"),
message: NSLocalizedString("Server operator changed.", comment: "alert title")
)
} else {
server = serverToEdit
validateServers_($userServers, $serverErrors)
dismiss()
}
} else {
dismiss()
showAlert(
NSLocalizedString("Invalid server address!", comment: "alert title"),
message: NSLocalizedString("Check server address and try again.", comment: "alert title")
)
}
})
.alert(isPresented: $showTestFailure) {
Alert(
@ -62,7 +86,7 @@ struct ProtocolServerView: View {
private func customServer() -> some View {
VStack {
let serverAddress = parseServerAddress(serverToEdit.server)
let valid = serverAddress?.valid == true && serverAddress?.serverProtocol == serverProtocol
let valid = serverAddress?.valid == true
List {
Section {
TextEditor(text: $serverToEdit.server)
@ -112,10 +136,7 @@ struct ProtocolServerView: View {
Spacer()
showTestStatus(server: serverToEdit)
}
let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
.disabled(useForNewDisabled)
.foregroundColor(useForNewDisabled ? theme.colors.secondary : theme.colors.onBackground)
}
}
}
@ -142,7 +163,7 @@ struct BackButton: ViewModifier {
}
}
@ViewBuilder func showTestStatus(server: ServerCfg) -> some View {
@ViewBuilder func showTestStatus(server: UserServer) -> some View {
switch server.tested {
case .some(true):
Image(systemName: "checkmark")
@ -155,7 +176,7 @@ struct BackButton: ViewModifier {
}
}
func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailure? {
func testServerConnection(server: Binding<UserServer>) async -> ProtocolTestFailure? {
do {
let r = try await testProtoServer(server: server.wrappedValue.server)
switch r {
@ -178,9 +199,11 @@ func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailu
struct ProtocolServerView_Previews: PreviewProvider {
static var previews: some View {
ProtocolServerView(
serverProtocol: .smp,
server: Binding.constant(ServerCfg.sampleData.custom),
serverToEdit: ServerCfg.sampleData.custom
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([]),
server: Binding.constant(UserServer.sampleData.custom),
serverToEdit: UserServer.sampleData.custom,
backLabel: "Your SMP servers"
)
}
}

View file

@ -0,0 +1,359 @@
//
// ProtocolServersView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 15/11/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")!
struct YourServersView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject private var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.editMode) private var editMode
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
var operatorIndex: Int
@State private var selectedServer: String? = nil
@State private var showAddServer = false
@State private var newServerNavLinkActive = false
@State private var showScanProtoServer = false
@State private var testing = false
var body: some View {
yourServersView()
.opacity(testing ? 0.4 : 1)
.overlay {
if testing {
ProgressView()
.scaleEffect(2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.allowsHitTesting(!testing)
}
@ViewBuilder private func yourServersView() -> some View {
let duplicateHosts = findDuplicateHosts(serverErrors)
List {
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
Section {
ForEach($userServers[operatorIndex].smpServers) { srv in
if !srv.wrappedValue.deleted {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .smp,
backLabel: "Your servers",
selectedServer: $selectedServer
)
} else {
EmptyView()
}
}
.onDelete { indexSet in
deleteSMPServer($userServers, operatorIndex, indexSet)
validateServers_($userServers, $serverErrors)
}
} header: {
Text("Message servers")
.foregroundColor(theme.colors.secondary)
} footer: {
if let errStr = globalSMPServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else {
Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
.foregroundColor(theme.colors.secondary)
.lineLimit(10)
}
}
}
if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty {
Section {
ForEach($userServers[operatorIndex].xftpServers) { srv in
if !srv.wrappedValue.deleted {
ProtocolServerViewLink(
userServers: $userServers,
serverErrors: $serverErrors,
duplicateHosts: duplicateHosts,
server: srv,
serverProtocol: .xftp,
backLabel: "Your servers",
selectedServer: $selectedServer
)
} else {
EmptyView()
}
}
.onDelete { indexSet in
deleteXFTPServer($userServers, operatorIndex, indexSet)
validateServers_($userServers, $serverErrors)
}
} header: {
Text("Media & file servers")
.foregroundColor(theme.colors.secondary)
} footer: {
if let errStr = globalXFTPServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else {
Text("The servers for new files of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
.foregroundColor(theme.colors.secondary)
.lineLimit(10)
}
}
}
Section {
ZStack {
Button("Add server") {
showAddServer = true
}
NavigationLink(isActive: $newServerNavLinkActive) {
newServerDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
} footer: {
if let errStr = globalServersError(serverErrors) {
ServersErrorView(errStr: errStr)
}
}
Section {
TestServersButton(
smpServers: $userServers[operatorIndex].smpServers,
xftpServers: $userServers[operatorIndex].xftpServers,
testing: $testing
)
howToButton()
}
}
.toolbar {
if (
!userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty ||
!userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty
) {
EditButton()
}
}
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
Button("Enter server manually") { newServerNavLinkActive = true }
Button("Scan server QR code") { showScanProtoServer = true }
}
.sheet(isPresented: $showScanProtoServer) {
ScanProtocolServer(
userServers: $userServers,
serverErrors: $serverErrors
)
.modifier(ThemedBackground(grouped: true))
}
}
private func newServerDestinationView() -> some View {
NewServerView(
userServers: $userServers,
serverErrors: $serverErrors
)
.navigationTitle("New server")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
}
func howToButton() -> some View {
Button {
DispatchQueue.main.async {
UIApplication.shared.open(howToUrl)
}
} label: {
HStack {
Text("How to use your servers")
Image(systemName: "arrow.up.right.circle")
}
}
}
}
struct ProtocolServerViewLink: View {
@EnvironmentObject var theme: AppTheme
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
var duplicateHosts: Set<String>
@Binding var server: UserServer
var serverProtocol: ServerProtocol
var backLabel: LocalizedStringKey
@Binding var selectedServer: String?
var body: some View {
let proto = serverProtocol.rawValue.uppercased()
NavigationLink(tag: server.id, selection: $selectedServer) {
ProtocolServerView(
userServers: $userServers,
serverErrors: $serverErrors,
server: $server,
serverToEdit: server,
backLabel: backLabel
)
.navigationBarTitle("\(proto) server")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
let address = parseServerAddress(server.server)
HStack {
Group {
if let address = address {
if !address.valid || address.serverProtocol != serverProtocol {
invalidServer()
} else if address.hostnames.contains(where: duplicateHosts.contains) {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
} else if !server.enabled {
Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
} else {
showTestStatus(server: server)
}
} else {
invalidServer()
}
}
.frame(width: 16, alignment: .center)
.padding(.trailing, 4)
let v = Text(address?.hostnames.first ?? server.server).lineLimit(1)
if server.enabled {
v
} else {
v.foregroundColor(theme.colors.secondary)
}
}
}
}
private func invalidServer() -> some View {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
}
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..<smpServers.count {
if smpServers[i].enabled {
smpServers[i].tested = nil
}
}
for i in 0..<xftpServers.count {
if xftpServers[i].enabled {
xftpServers[i].tested = nil
}
}
}
private func runServersTest() async -> [String: ProtocolTestFailure] {
var fs: [String: ProtocolTestFailure] = [:]
for i in 0..<smpServers.count {
if smpServers[i].enabled {
if let f = await testServerConnection(server: $smpServers[i]) {
fs[serverHostname(smpServers[i].server)] = f
}
}
}
for i in 0..<xftpServers.count {
if xftpServers[i].enabled {
if let f = await testServerConnection(server: $xftpServers[i]) {
fs[serverHostname(xftpServers[i].server)] = f
}
}
}
return fs
}
}
struct YourServersView_Previews: PreviewProvider {
static var previews: some View {
YourServersView(
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([]),
operatorIndex: 1
)
}
}

View file

@ -12,8 +12,8 @@ import CodeScanner
struct ScanProtocolServer: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var servers: [ServerCfg]
@State private var showAddressError = false
@Binding var userServers: [UserOperatorServers]
@Binding var serverErrors: [UserServersError]
var body: some View {
VStack(alignment: .leading) {
@ -28,23 +28,14 @@ struct ScanProtocolServer: View {
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(isPresented: $showAddressError) {
Alert(
title: Text("Invalid server address!"),
message: Text("Check server address and try again.")
)
}
}
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r):
if parseServerAddress(r.string) != nil {
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false))
dismiss()
} else {
showAddressError = true
}
var server: UserServer = .empty
server.server = r.string
addServer(server, $userServers, $serverErrors, dismiss)
case let .failure(e):
logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)")
dismiss()
@ -54,6 +45,9 @@ struct ScanProtocolServer: View {
struct ScanProtocolServer_Previews: PreviewProvider {
static var previews: some View {
ScanProtocolServer(servers: Binding.constant([]))
ScanProtocolServer(
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
serverErrors: Binding.constant([])
)
}
}

View file

@ -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<ServerCfg>) -> 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..<configuredServers.count {
if configuredServers[i].enabled {
configuredServers[i].tested = nil
}
}
for i in 0..<otherServers.count {
if otherServers[i].enabled {
otherServers[i].tested = nil
}
}
}
private func runServersTest() async -> [String: ProtocolTestFailure] {
var fs: [String: ProtocolTestFailure] = [:]
for i in 0..<configuredServers.count {
if configuredServers[i].enabled {
if let f = await testServerConnection(server: $configuredServers[i]) {
fs[serverHostname(configuredServers[i].server)] = f
}
}
}
for i in 0..<otherServers.count {
if otherServers[i].enabled {
if let f = await testServerConnection(server: $otherServers[i]) {
fs[serverHostname(otherServers[i].server)] = f
}
}
}
return fs
}
func saveServers() {
Task {
do {
try await setUserProtoServers(serverProtocol, servers: allServers)
await MainActor.run {
currServers = allServers
editMode?.wrappedValue = .inactive
}
} catch let error {
let err = responseError(error)
logger.error("saveServers setUserProtocolServers error: \(err)")
await MainActor.run {
alert = .error(
title: "Error saving \(proto) servers",
error: "Make sure \(proto) server addresses are in correct format, line separated and are not duplicated (\(responseError(error)))."
)
}
}
}
}
}
struct ProtocolServersView_Previews: PreviewProvider {
static var previews: some View {
ProtocolServersView(serverProtocol: .smp)
}
}

View file

@ -50,6 +50,7 @@ let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
@ -107,6 +108,7 @@ let appDefaults: [String: Any] = [
DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness,
DEFAULT_CHAT_ITEM_TAIL: true,
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false,
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
@ -135,6 +137,7 @@ let appDefaults: [String: Any] = [
let hintDefaults = [
DEFAULT_LA_NOTICE_SHOWN,
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
DEFAULT_SHOW_MUTE_PROFILE_ALERT,
@ -356,7 +359,7 @@ struct SettingsView: View {
}
}
NavigationLink {
WhatsNewView(viaSettings: true)
WhatsNewView(viaSettings: true, updatedConditions: false)
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.inline)
} label: {

View file

@ -9,15 +9,47 @@
import SwiftUI
struct UserAddressLearnMore: View {
@State var showCreateAddressButton = false
@State private var createAddressLinkActive = false
var body: some View {
List {
VStack(alignment: .leading, spacing: 18) {
Text("You can share your address as a link or QR code - anybody can connect to you.")
Text("You won't lose your contacts if you later delete your address.")
Text("When people request to connect, you can accept or reject it.")
Text("Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).")
VStack {
List {
VStack(alignment: .leading, spacing: 18) {
Text("You can share your address as a link or QR code - anybody can connect to you.")
Text("You won't lose your contacts if you later delete your address.")
Text("When people request to connect, you can accept or reject it.")
Text("Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).")
}
.listRowBackground(Color.clear)
}
.listRowBackground(Color.clear)
.frame(maxHeight: .infinity)
if showCreateAddressButton {
addressCreationButton()
.padding()
}
}
}
private func addressCreationButton() -> some View {
ZStack {
Button {
createAddressLinkActive = true
} label: {
Text("Create SimpleX address")
}
.buttonStyle(OnboardingButtonStyle())
NavigationLink(isActive: $createAddressLinkActive) {
UserAddressView(autoCreate: true)
.navigationTitle("SimpleX address")
.navigationBarTitleDisplayMode(.large)
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
}

View file

@ -15,6 +15,7 @@ struct UserAddressView: View {
@EnvironmentObject private var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State var shareViaProfile = false
@State var autoCreate = false
@State private var aas = AutoAcceptState()
@State private var savedAAS = AutoAcceptState()
@State private var ignoreShareViaProfileChange = false
@ -67,6 +68,11 @@ struct UserAddressView: View {
}
}
}
.onAppear {
if chatModel.userAddress == nil, autoCreate {
createAddress()
}
}
}
@Namespace private var bottomID
@ -212,26 +218,30 @@ struct UserAddressView: View {
private func createAddressButton() -> some View {
Button {
progressIndicator = true
Task {
do {
let connReqContact = try await apiCreateUserAddress()
DispatchQueue.main.async {
chatModel.userAddress = UserContactLink(connReqContact: connReqContact)
alert = .shareOnCreate
progressIndicator = false
}
} catch let error {
logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
let a = getErrorAlert(error, "Error creating address")
alert = .error(title: a.title, error: a.message)
await MainActor.run { progressIndicator = false }
}
}
createAddress()
} label: {
Label("Create SimpleX address", systemImage: "qrcode")
}
}
private func createAddress() {
progressIndicator = true
Task {
do {
let connReqContact = try await apiCreateUserAddress()
DispatchQueue.main.async {
chatModel.userAddress = UserContactLink(connReqContact: connReqContact)
alert = .shareOnCreate
progressIndicator = false
}
} catch let error {
logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
let a = getErrorAlert(error, "Error creating address")
alert = .error(title: a.title, error: a.message)
await MainActor.run { progressIndicator = false }
}
}
}
private func deleteAddressButton() -> some View {
Button(role: .destructive) {

View file

@ -144,20 +144,22 @@
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */; };
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; };
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; };
6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; };
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; };
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642BA82C2CE50495005E9412 /* NewServerView.swift */; };
642BA8332CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */; };
642BA8342CEB3D4B005E9412 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82F2CEB3D4B005E9412 /* libffi.a */; };
642BA8352CEB3D4B005E9412 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8302CEB3D4B005E9412 /* libgmp.a */; };
642BA8362CEB3D4B005E9412 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8312CEB3D4B005E9412 /* libgmpxx.a */; };
642BA8372CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */; };
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B402CCBEB080083A2CF /* libgmpxx.a */; };
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */; };
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B422CCBEB080083A2CF /* libffi.a */; };
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */; };
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B442CCBEB080083A2CF /* libgmp.a */; };
643B3B4E2CCFD6400083A2CF /* OperatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */; };
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; };
6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; };
64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */; };
64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DCB29FFE3E800E3D48D /* MailView.swift */; };
6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; };
644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; };
@ -200,7 +202,9 @@
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; };
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */; };
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; };
CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; };
CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; };
@ -436,7 +440,7 @@
5CB634AC29E46CF70066AD6B /* LocalAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthView.swift; sourceTree = "<group>"; };
5CB634AE29E4BB7D0066AD6B /* SetAppPasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAppPasscodeView.swift; sourceTree = "<group>"; };
5CB634B029E5EFEA0066AD6B /* PasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasscodeView.swift; sourceTree = "<group>"; };
5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; wrapsLines = 0; };
5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = "<group>"; };
5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListNavLink.swift; sourceTree = "<group>"; };
5CBD285529565CAE00EC2CF4 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
@ -487,20 +491,22 @@
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatMenuButton.swift; sourceTree = "<group>"; };
640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = "<group>"; };
640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = "<group>"; };
6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = "<group>"; };
6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextInvitingContactMemberView.swift; sourceTree = "<group>"; };
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = "<group>"; };
642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a"; sourceTree = "<group>"; };
642BA82F2CEB3D4B005E9412 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
642BA8302CEB3D4B005E9412 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
642BA8312CEB3D4B005E9412 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a"; sourceTree = "<group>"; };
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
643B3B402CCBEB080083A2CF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmpxx.a; path = Libraries/libgmpxx.a; sourceTree = "<group>"; };
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a"; sourceTree = "<group>"; };
643B3B422CCBEB080083A2CF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libffi.a; path = Libraries/libffi.a; sourceTree = "<group>"; };
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a"; path = "Libraries/libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a"; sourceTree = "<group>"; };
643B3B442CCBEB080083A2CF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmp.a; path = Libraries/libgmp.a; sourceTree = "<group>"; };
643B3B4D2CCFD6400083A2CF /* OperatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorView.swift; sourceTree = "<group>"; };
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = "<group>"; };
64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = "<group>"; };
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = "<group>"; };
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = "<group>"; };
@ -544,7 +550,9 @@
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = "<group>"; };
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; };
@ -657,14 +665,14 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */,
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */,
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a in Frameworks */,
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a in Frameworks */,
642BA8342CEB3D4B005E9412 /* libffi.a in Frameworks */,
642BA8352CEB3D4B005E9412 /* libgmp.a in Frameworks */,
642BA8372CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a in Frameworks */,
642BA8332CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a in Frameworks */,
642BA8362CEB3D4B005E9412 /* libgmpxx.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -741,6 +749,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
642BA82F2CEB3D4B005E9412 /* libffi.a */,
642BA8302CEB3D4B005E9412 /* libgmp.a */,
642BA8312CEB3D4B005E9412 /* libgmpxx.a */,
642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */,
642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */,
);
path = Libraries;
sourceTree = "<group>";
@ -812,11 +825,6 @@
5CC2C0FA2809BF11000C35E3 /* Localizable.strings */,
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */,
5C764E5C279C70B7000C6508 /* Libraries */,
643B3B422CCBEB080083A2CF /* libffi.a */,
643B3B442CCBEB080083A2CF /* libgmp.a */,
643B3B402CCBEB080083A2CF /* libgmpxx.a */,
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */,
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */,
5CA059C2279559F40002BEB4 /* Shared */,
5CDCAD462818589900503DA2 /* SimpleX NSE */,
CEE723A82C3BD3D70009AE93 /* SimpleX SE */,
@ -875,13 +883,15 @@
5CB0BA8C282711BC00B3292C /* Onboarding */ = {
isa = PBXGroup;
children = (
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */,
5CB0BA8D2827126500B3292C /* OnboardingView.swift */,
5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */,
5CB0BA992827FD8800B3292C /* HowItWorks.swift */,
5CB0BA91282713FD00B3292C /* CreateProfile.swift */,
64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */,
5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */,
5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */,
640743602CD360E600158442 /* ChooseServerOperators.swift */,
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */,
);
path = Onboarding;
sourceTree = "<group>";
@ -912,10 +922,9 @@
5CB924DF27A8678B00ACCCDD /* UserSettings */ = {
isa = PBXGroup;
children = (
643B3B4C2CCFD34B0083A2CF /* NetworkAndServers */,
5CB924D627A8563F00ACCCDD /* SettingsView.swift */,
5CB346E62868D76D001FD2EF /* NotificationsView.swift */,
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
5CADE79929211BB900072E13 /* PreferencesView.swift */,
5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */,
5C05DF522840AA1D00C683F9 /* CallSettings.swift */,
@ -923,9 +932,6 @@
5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */,
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */,
5C93292E29239A170090FFF9 /* ProtocolServersView.swift */,
5C93293029239BED0090FFF9 /* ProtocolServerView.swift */,
5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */,
5CB2084E28DA4B4800D024EC /* RTCServers.swift */,
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */,
18415845648CA4F5A8BCA272 /* UserProfilesView.swift */,
@ -1056,6 +1062,20 @@
path = Database;
sourceTree = "<group>";
};
643B3B4C2CCFD34B0083A2CF /* NetworkAndServers */ = {
isa = PBXGroup;
children = (
5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */,
642BA82C2CE50495005E9412 /* NewServerView.swift */,
5C93293029239BED0090FFF9 /* ProtocolServerView.swift */,
5C93292E29239A170090FFF9 /* ProtocolServersView.swift */,
643B3B4D2CCFD6400083A2CF /* OperatorView.swift */,
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
);
path = NetworkAndServers;
sourceTree = "<group>";
};
6440CA01288AEC770062C672 /* Group */ = {
isa = PBXGroup;
children = (
@ -1375,10 +1395,12 @@
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */,
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */,
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */,
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */,
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */,
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */,
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */,
5C65DAF929D0CC20003CEE45 /* DeveloperView.swift in Sources */,
@ -1405,12 +1427,12 @@
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */,
5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */,
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */,
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */,
5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */,
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */,
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */,
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */,
64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */,
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */,
@ -1528,7 +1550,9 @@
5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */,
18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */,
18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */,
642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */,
184158C131FDB829D8A117EA /* VideoPlayerView.swift in Sources */,
643B3B4E2CCFD6400083A2CF /* OperatorView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -72,9 +72,15 @@ public enum ChatCommand {
case apiGetGroupLink(groupId: Int64)
case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64)
case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent)
case apiGetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol)
case apiSetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol, servers: [ServerCfg])
case apiTestProtoServer(userId: Int64, server: String)
case apiGetServerOperators
case apiSetServerOperators(operators: [ServerOperator])
case apiGetUserServers(userId: Int64)
case apiSetUserServers(userId: Int64, userServers: [UserOperatorServers])
case apiValidateServers(userId: Int64, userServers: [UserOperatorServers])
case apiGetUsageConditions
case apiSetConditionsNotified(conditionsId: Int64)
case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64])
case apiSetChatItemTTL(userId: Int64, seconds: Int64?)
case apiGetChatItemTTL(userId: Int64)
case apiSetNetworkConfig(networkConfig: NetCfg)
@ -231,9 +237,15 @@ public enum ChatCommand {
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)"
case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)"
case let .apiGetUserProtoServers(userId, serverProtocol): return "/_servers \(userId) \(serverProtocol)"
case let .apiSetUserProtoServers(userId, serverProtocol, servers): return "/_servers \(userId) \(serverProtocol) \(protoServersStr(servers))"
case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)"
case .apiGetServerOperators: return "/_operators"
case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))"
case let .apiGetUserServers(userId): return "/_servers \(userId)"
case let .apiSetUserServers(userId, userServers): return "/_servers \(userId) \(encodeJSON(userServers))"
case let .apiValidateServers(userId, userServers): return "/_validate_servers \(userId) \(encodeJSON(userServers))"
case .apiGetUsageConditions: return "/_conditions"
case let .apiSetConditionsNotified(conditionsId): return "/_conditions_notified \(conditionsId)"
case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))"
case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
@ -386,9 +398,15 @@ public enum ChatCommand {
case .apiGetGroupLink: return "apiGetGroupLink"
case .apiCreateMemberContact: return "apiCreateMemberContact"
case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation"
case .apiGetUserProtoServers: return "apiGetUserProtoServers"
case .apiSetUserProtoServers: return "apiSetUserProtoServers"
case .apiTestProtoServer: return "apiTestProtoServer"
case .apiGetServerOperators: return "apiGetServerOperators"
case .apiSetServerOperators: return "apiSetServerOperators"
case .apiGetUserServers: return "apiGetUserServers"
case .apiSetUserServers: return "apiSetUserServers"
case .apiValidateServers: return "apiValidateServers"
case .apiGetUsageConditions: return "apiGetUsageConditions"
case .apiSetConditionsNotified: return "apiSetConditionsNotified"
case .apiAcceptConditions: return "apiAcceptConditions"
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
@ -475,10 +493,6 @@ public enum ChatCommand {
func joinedIds(_ ids: [Int64]) -> String {
ids.map { "\($0)" }.joined(separator: ",")
}
func protoServersStr(_ servers: [ServerCfg]) -> String {
encodeJSON(ProtoServersConfig(servers: servers))
}
func chatItemTTLStr(seconds: Int64?) -> String {
if let seconds = seconds {
@ -548,8 +562,11 @@ public enum ChatResponse: Decodable, Error {
case apiChats(user: UserRef, chats: [ChatData])
case apiChat(user: UserRef, chat: ChatData)
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
case userProtoServers(user: UserRef, servers: UserProtoServers)
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
case serverOperatorConditions(conditions: ServerOperatorConditions)
case userServers(user: UserRef, userServers: [UserOperatorServers])
case userServersValidation(user: UserRef, serverErrors: [UserServersError])
case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?)
case chatItemTTL(user: UserRef, chatItemTTL: Int64?)
case networkConfig(networkConfig: NetCfg)
case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?)
@ -721,8 +738,11 @@ public enum ChatResponse: Decodable, Error {
case .apiChats: return "apiChats"
case .apiChat: return "apiChat"
case .chatItemInfo: return "chatItemInfo"
case .userProtoServers: return "userProtoServers"
case .serverTestResult: return "serverTestResult"
case .serverOperatorConditions: return "serverOperators"
case .userServers: return "userServers"
case .userServersValidation: return "userServersValidation"
case .usageConditions: return "usageConditions"
case .chatItemTTL: return "chatItemTTL"
case .networkConfig: return "networkConfig"
case .contactInfo: return "contactInfo"
@ -890,8 +910,11 @@ public enum ChatResponse: Decodable, Error {
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))")
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))"
case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))")
case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))")
case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))"
case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL))
case let .networkConfig(networkConfig): return String(describing: networkConfig)
case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))")
@ -1175,86 +1198,428 @@ public struct DBEncryptionConfig: Codable {
public var newKey: String
}
struct SMPServersConfig: Encodable {
var smpServers: [ServerCfg]
}
public enum ServerProtocol: String, Decodable {
case smp
case xftp
}
public struct ProtoServersConfig: Codable {
public var servers: [ServerCfg]
public enum OperatorTag: String, Codable {
case simplex = "simplex"
case flux = "flux"
case xyz = "xyz"
case demo = "demo"
}
public struct UserProtoServers: Decodable {
public var serverProtocol: ServerProtocol
public var protoServers: [ServerCfg]
public var presetServers: [ServerCfg]
public struct ServerOperatorInfo: Decodable {
public var description: [String]
public var website: String
public var logo: String
public var largeLogo: String
public var logoDarkMode: String
public var largeLogoDarkMode: String
}
public struct ServerCfg: Identifiable, Equatable, Codable, Hashable {
public let operatorsInfo: Dictionary<OperatorTag, ServerOperatorInfo> = [
.simplex: ServerOperatorInfo(
description: [
"SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.",
"SimpleX Chat Ltd develops the communication software for SimpleX network."
],
website: "https://simplex.chat",
logo: "decentralized",
largeLogo: "logo",
logoDarkMode: "decentralized-light",
largeLogoDarkMode: "logo-light"
),
.flux: ServerOperatorInfo(
description: [
"Flux is the largest decentralized cloud infrastructure, leveraging a global network of user-operated computational nodes.",
"Flux offers a powerful, scalable, and affordable platform designed to support individuals, businesses, and cutting-edge technologies like AI. With high uptime and worldwide distribution, Flux ensures reliable, accessible cloud computing for all."
],
website: "https://runonflux.com",
logo: "flux_logo_symbol",
largeLogo: "flux_logo",
logoDarkMode: "flux_logo_symbol",
largeLogoDarkMode: "flux_logo-light"
),
.xyz: ServerOperatorInfo(
description: ["XYZ servers"],
website: "XYZ website",
logo: "shield",
largeLogo: "logo",
logoDarkMode: "shield",
largeLogoDarkMode: "logo-light"
),
.demo: ServerOperatorInfo(
description: ["Demo operator"],
website: "Demo website",
logo: "decentralized",
largeLogo: "logo",
logoDarkMode: "decentralized-light",
largeLogoDarkMode: "logo-light"
)
]
public struct UsageConditions: Decodable {
public var conditionsId: Int64
public var conditionsCommit: String
public var notifiedAt: Date?
public var createdAt: Date
public static var sampleData = UsageConditions(
conditionsId: 1,
conditionsCommit: "11a44dc1fd461a93079f897048b46998db55da5c",
notifiedAt: nil,
createdAt: Date.now
)
}
public enum UsageConditionsAction: Decodable {
case review(operators: [ServerOperator], deadline: Date?, showNotice: Bool)
case accepted(operators: [ServerOperator])
public var showNotice: Bool {
switch self {
case let .review(_, _, showNotice): showNotice
case .accepted: false
}
}
}
public struct ServerOperatorConditions: Decodable {
public var serverOperators: [ServerOperator]
public var currentConditions: UsageConditions
public var conditionsAction: UsageConditionsAction?
public static var empty = ServerOperatorConditions(
serverOperators: [],
currentConditions: UsageConditions(conditionsId: 0, conditionsCommit: "empty", notifiedAt: nil, createdAt: .now),
conditionsAction: nil
)
}
public enum ConditionsAcceptance: Equatable, Codable, Hashable {
case accepted(acceptedAt: Date?)
// If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator.
// No deadline indicates it's required to accept conditions for the operator to start using it.
case required(deadline: Date?)
public var conditionsAccepted: Bool {
switch self {
case .accepted: true
case .required: false
}
}
public var usageAllowed: Bool {
switch self {
case .accepted: true
case let .required(deadline): deadline != nil
}
}
}
public struct ServerOperator: Identifiable, Equatable, Codable {
public var operatorId: Int64
public var operatorTag: OperatorTag?
public var tradeName: String
public var legalName: String?
public var serverDomains: [String]
public var conditionsAcceptance: ConditionsAcceptance
public var enabled: Bool
public var smpRoles: ServerRoles
public var xftpRoles: ServerRoles
public var id: Int64 { operatorId }
public static func == (l: ServerOperator, r: ServerOperator) -> Bool {
l.operatorId == r.operatorId && l.operatorTag == r.operatorTag && l.tradeName == r.tradeName && l.legalName == r.legalName &&
l.serverDomains == r.serverDomains && l.conditionsAcceptance == r.conditionsAcceptance && l.enabled == r.enabled &&
l.smpRoles == r.smpRoles && l.xftpRoles == r.xftpRoles
}
public var legalName_: String {
legalName ?? tradeName
}
public var info: ServerOperatorInfo {
return if let operatorTag = operatorTag {
operatorsInfo[operatorTag] ?? ServerOperator.dummyOperatorInfo
} else {
ServerOperator.dummyOperatorInfo
}
}
public static let dummyOperatorInfo = ServerOperatorInfo(
description: ["Default"],
website: "Default",
logo: "decentralized",
largeLogo: "logo",
logoDarkMode: "decentralized-light",
largeLogoDarkMode: "logo-light"
)
public func logo(_ colorScheme: ColorScheme) -> String {
colorScheme == .light ? info.logo : info.logoDarkMode
}
public func largeLogo(_ colorScheme: ColorScheme) -> String {
colorScheme == .light ? info.largeLogo : info.largeLogoDarkMode
}
public static var sampleData1 = ServerOperator(
operatorId: 1,
operatorTag: .simplex,
tradeName: "SimpleX Chat",
legalName: "SimpleX Chat Ltd",
serverDomains: ["simplex.im"],
conditionsAcceptance: .accepted(acceptedAt: nil),
enabled: true,
smpRoles: ServerRoles(storage: true, proxy: true),
xftpRoles: ServerRoles(storage: true, proxy: true)
)
public static var sampleData2 = ServerOperator(
operatorId: 2,
operatorTag: .xyz,
tradeName: "XYZ",
legalName: nil,
serverDomains: ["xyz.com"],
conditionsAcceptance: .required(deadline: nil),
enabled: false,
smpRoles: ServerRoles(storage: false, proxy: true),
xftpRoles: ServerRoles(storage: false, proxy: true)
)
public static var sampleData3 = ServerOperator(
operatorId: 3,
operatorTag: .demo,
tradeName: "Demo",
legalName: nil,
serverDomains: ["demo.com"],
conditionsAcceptance: .required(deadline: nil),
enabled: false,
smpRoles: ServerRoles(storage: true, proxy: false),
xftpRoles: ServerRoles(storage: true, proxy: false)
)
}
public struct ServerRoles: Equatable, Codable {
public var storage: Bool
public var proxy: Bool
}
public struct UserOperatorServers: Identifiable, Equatable, Codable {
public var `operator`: ServerOperator?
public var smpServers: [UserServer]
public var xftpServers: [UserServer]
public var id: String {
if let op = self.operator {
"\(op.operatorId)"
} else {
"nil operator"
}
}
public var operator_: ServerOperator {
get {
self.operator ?? ServerOperator(
operatorId: 0,
operatorTag: nil,
tradeName: "",
legalName: "",
serverDomains: [],
conditionsAcceptance: .accepted(acceptedAt: nil),
enabled: false,
smpRoles: ServerRoles(storage: true, proxy: true),
xftpRoles: ServerRoles(storage: true, proxy: true)
)
}
set { `operator` = newValue }
}
public static var sampleData1 = UserOperatorServers(
operator: ServerOperator.sampleData1,
smpServers: [UserServer.sampleData.preset],
xftpServers: [UserServer.sampleData.xftpPreset]
)
public static var sampleDataNilOperator = UserOperatorServers(
operator: nil,
smpServers: [UserServer.sampleData.preset],
xftpServers: [UserServer.sampleData.xftpPreset]
)
}
public enum UserServersError: Decodable {
case noServers(protocol: ServerProtocol, user: UserRef?)
case storageMissing(protocol: ServerProtocol, user: UserRef?)
case proxyMissing(protocol: ServerProtocol, user: UserRef?)
case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String)
public var globalError: String? {
switch self {
case let .noServers(`protocol`, _):
switch `protocol` {
case .smp: return globalSMPError
case .xftp: return globalXFTPError
}
case let .storageMissing(`protocol`, _):
switch `protocol` {
case .smp: return globalSMPError
case .xftp: return globalXFTPError
}
case let .proxyMissing(`protocol`, _):
switch `protocol` {
case .smp: return globalSMPError
case .xftp: return globalXFTPError
}
default: return nil
}
}
public var globalSMPError: String? {
switch self {
case let .noServers(.smp, user):
let text = NSLocalizedString("No message servers.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
case let .storageMissing(.smp, user):
let text = NSLocalizedString("No servers to receive messages.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
case let .proxyMissing(.smp, user):
let text = NSLocalizedString("No servers for private message routing.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
default:
return nil
}
}
public var globalXFTPError: String? {
switch self {
case let .noServers(.xftp, user):
let text = NSLocalizedString("No media & file servers.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
case let .storageMissing(.xftp, user):
let text = NSLocalizedString("No servers to send files.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
case let .proxyMissing(.xftp, user):
let text = NSLocalizedString("No servers to receive files.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
default:
return nil
}
}
private func userStr(_ user: UserRef) -> String {
String.localizedStringWithFormat(NSLocalizedString("For chat profile %@:", comment: "servers error"), user.localDisplayName)
}
}
public struct UserServer: Identifiable, Equatable, Codable, Hashable {
public var serverId: Int64?
public var server: String
public var preset: Bool
public var tested: Bool?
public var enabled: Bool
public var deleted: Bool
var createdAt = Date()
// public var sendEnabled: Bool // can we potentially want to prevent sending on the servers we use to receive?
// Even if we don't see the use case, it's probably better to allow it in the model
// In any case, "trusted/known" servers are out of scope of this change
public init(server: String, preset: Bool, tested: Bool?, enabled: Bool) {
public init(serverId: Int64?, server: String, preset: Bool, tested: Bool?, enabled: Bool, deleted: Bool) {
self.serverId = serverId
self.server = server
self.preset = preset
self.tested = tested
self.enabled = enabled
self.deleted = deleted
}
public static func == (l: ServerCfg, r: ServerCfg) -> Bool {
l.server == r.server && l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled
public static func == (l: UserServer, r: UserServer) -> Bool {
l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested &&
l.enabled == r.enabled && l.deleted == r.deleted
}
public var id: String { "\(server) \(createdAt)" }
public static var empty = ServerCfg(server: "", preset: false, tested: nil, enabled: false)
public static var empty = UserServer(serverId: nil, server: "", preset: false, tested: nil, enabled: false, deleted: false)
public var isEmpty: Bool {
server.trimmingCharacters(in: .whitespaces) == ""
}
public struct SampleData {
public var preset: ServerCfg
public var custom: ServerCfg
public var untested: ServerCfg
public var preset: UserServer
public var custom: UserServer
public var untested: UserServer
public var xftpPreset: UserServer
}
public static var sampleData = SampleData(
preset: ServerCfg(
preset: UserServer(
serverId: 1,
server: "smp://abcd@smp8.simplex.im",
preset: true,
tested: true,
enabled: true
enabled: true,
deleted: false
),
custom: ServerCfg(
custom: UserServer(
serverId: 2,
server: "smp://abcd@smp9.simplex.im",
preset: false,
tested: false,
enabled: false
enabled: false,
deleted: false
),
untested: ServerCfg(
untested: UserServer(
serverId: 3,
server: "smp://abcd@smp10.simplex.im",
preset: false,
tested: nil,
enabled: true
enabled: true,
deleted: false
),
xftpPreset: UserServer(
serverId: 4,
server: "xftp://abcd@xftp8.simplex.im",
preset: true,
tested: true,
enabled: true,
deleted: false
)
)
enum CodingKeys: CodingKey {
case serverId
case server
case preset
case tested
case enabled
case deleted
}
}
@ -1357,7 +1722,7 @@ public struct NetCfg: Codable, Equatable {
public var hostMode: HostMode = .publicHost
public var requiredHostMode = true
public var sessionMode = TransportSessionMode.user
public var smpProxyMode: SMPProxyMode = .unknown
public var smpProxyMode: SMPProxyMode = .always
public var smpProxyFallback: SMPProxyFallback = .allowProtected
public var smpWebPort = false
public var tcpConnectTimeout: Int // microseconds

View file

@ -1,13 +1,13 @@
package chat.simplex.common.views.usersettings
package chat.simplex.common.views.usersettings.networkAndServers
import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import chat.simplex.common.model.ServerCfg
import chat.simplex.common.model.UserServer
import com.google.accompanist.permissions.rememberPermissionState
@Composable
actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) {
actual fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()

View file

@ -15,7 +15,6 @@ import androidx.compose.ui.draw.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
@ -42,7 +41,6 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.math.absoluteValue
@Composable
fun AppScreen() {
@ -194,6 +192,13 @@ fun MainScreen() {
OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
OnboardingStage.LinkAMobile -> LinkAMobile()
OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
OnboardingStage.Step3_ChooseServerOperators -> {
val modalData = remember { ModalData() }
modalData.ChooseServerOperators(true)
if (appPlatform.isDesktop) {
ModalManager.fullscreen.showInView()
}
}
// Ensure backwards compatibility with old onboarding stage for address creation, otherwise notification setup would be skipped
OnboardingStage.Step3_CreateSimpleXAddress -> SetNotificationsMode(chatModel)
OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)

View file

@ -145,6 +145,8 @@ object ChatModel {
val clipboardHasText = mutableStateOf(false)
val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true))
val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty)
val updatingProgress = mutableStateOf(null as Float?)
var updatingRequest: Closeable? = null
@ -2567,6 +2569,13 @@ fun localTimestamp(t: Instant): String {
return ts.toJavaLocalDateTime().format(dateFormatter)
}
fun localDate(t: Instant): String {
val tz = TimeZone.currentSystemDefault()
val ts: LocalDateTime = t.toLocalDateTime(tz)
val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
return ts.toJavaLocalDateTime().format(dateFormatter)
}
@Serializable
sealed class CIStatus {
@Serializable @SerialName("sndNew") class SndNew: CIStatus()

View file

@ -26,10 +26,12 @@ import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert
import chat.simplex.common.views.migration.MigrationFileLinkData
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.serverHostname
import com.charleskorn.kaml.Yaml
import com.charleskorn.kaml.YamlConfiguration
import chat.simplex.res.MR
import com.russhwolf.settings.Settings
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
@ -963,36 +965,6 @@ object ChatController {
return null
}
suspend fun getUserProtoServers(rh: Long?, serverProtocol: ServerProtocol): UserProtocolServers? {
val userId = kotlin.runCatching { currentUserId("getUserProtoServers") }.getOrElse { return null }
val r = sendCmd(rh, CC.APIGetUserProtoServers(userId, serverProtocol))
return if (r is CR.UserProtoServers) { if (rh == null) r.servers else r.servers.copy(protoServers = r.servers.protoServers.map { it.copy(remoteHostId = rh) }) }
else {
Log.e(TAG, "getUserProtoServers bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_loading_smp_servers else MR.strings.error_loading_xftp_servers),
"${r.responseType}: ${r.details}"
)
null
}
}
suspend fun setUserProtoServers(rh: Long?, serverProtocol: ServerProtocol, servers: List<ServerCfg>): Boolean {
val userId = kotlin.runCatching { currentUserId("setUserProtoServers") }.getOrElse { return false }
val r = sendCmd(rh, CC.APISetUserProtoServers(userId, serverProtocol, servers))
return when (r) {
is CR.CmdOk -> true
else -> {
Log.e(TAG, "setUserProtoServers bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_saving_smp_servers else MR.strings.error_saving_xftp_servers),
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.ensure_smp_server_address_are_correct_format_and_unique else MR.strings.ensure_xftp_server_address_are_correct_format_and_unique)
)
false
}
}
}
suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? {
val userId = currentUserId("testProtoServer")
val r = sendCmd(rh, CC.APITestProtoServer(userId, server))
@ -1005,6 +977,106 @@ object ChatController {
}
}
suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? {
val r = sendCmd(rh, CC.ApiGetServerOperators())
return when (r) {
is CR.ServerOperatorConditions -> r.conditions
else -> {
Log.e(TAG, "getServerOperators bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun setServerOperators(rh: Long?, operators: List<ServerOperator>): ServerOperatorConditionsDetail? {
val r = sendCmd(rh, CC.ApiSetServerOperators(operators))
return when (r) {
is CR.ServerOperatorConditions -> r.conditions
else -> {
Log.e(TAG, "setServerOperators bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun getUserServers(rh: Long?): List<UserOperatorServers>? {
val userId = currentUserId("getUserServers")
val r = sendCmd(rh, CC.ApiGetUserServers(userId))
return when (r) {
is CR.UserServers -> r.userServers
else -> {
Log.e(TAG, "getUserServers bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun setUserServers(rh: Long?, userServers: List<UserOperatorServers>): Boolean {
val userId = currentUserId("setUserServers")
val r = sendCmd(rh, CC.ApiSetUserServers(userId, userServers))
return when (r) {
is CR.CmdOk -> true
else -> {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.failed_to_save_servers),
"${r.responseType}: ${r.details}"
)
Log.e(TAG, "setUserServers bad response: ${r.responseType} ${r.details}")
false
}
}
}
suspend fun validateServers(rh: Long?, userServers: List<UserOperatorServers>): List<UserServersError>? {
val userId = currentUserId("validateServers")
val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers))
return when (r) {
is CR.UserServersValidation -> r.serverErrors
else -> {
Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun getUsageConditions(rh: Long?): Triple<UsageConditionsDetail, String?, UsageConditionsDetail?>? {
val r = sendCmd(rh, CC.ApiGetUsageConditions())
return when (r) {
is CR.UsageConditions -> Triple(r.usageConditions, r.conditionsText, r.acceptedConditions)
else -> {
Log.e(TAG, "getUsageConditions bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun setConditionsNotified(rh: Long?, conditionsId: Long): Boolean {
val r = sendCmd(rh, CC.ApiSetConditionsNotified(conditionsId))
return when (r) {
is CR.CmdOk -> true
else -> {
Log.e(TAG, "setConditionsNotified bad response: ${r.responseType} ${r.details}")
false
}
}
}
suspend fun acceptConditions(rh: Long?, conditionsId: Long, operatorIds: List<Long>): ServerOperatorConditionsDetail? {
val r = sendCmd(rh, CC.ApiAcceptConditions(conditionsId, operatorIds))
return when (r) {
is CR.ServerOperatorConditions -> r.conditions
else -> {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.error_accepting_operator_conditions),
"${r.responseType}: ${r.details}"
)
Log.e(TAG, "acceptConditions bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun getChatItemTTL(rh: Long?): ChatItemTTL {
val userId = currentUserId("getChatItemTTL")
val r = sendCmd(rh, CC.APIGetChatItemTTL(userId))
@ -3037,9 +3109,15 @@ sealed class CC {
class APIGetGroupLink(val groupId: Long): CC()
class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC()
class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC()
class APIGetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol): CC()
class APISetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol, val servers: List<ServerCfg>): CC()
class APITestProtoServer(val userId: Long, val server: String): CC()
class ApiGetServerOperators(): CC()
class ApiSetServerOperators(val operators: List<ServerOperator>): CC()
class ApiGetUserServers(val userId: Long): CC()
class ApiSetUserServers(val userId: Long, val userServers: List<UserOperatorServers>): CC()
class ApiValidateServers(val userId: Long, val userServers: List<UserOperatorServers>): CC()
class ApiGetUsageConditions(): CC()
class ApiSetConditionsNotified(val conditionsId: Long): CC()
class ApiAcceptConditions(val conditionsId: Long, val operatorIds: List<Long>): CC()
class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC()
class APIGetChatItemTTL(val userId: Long): CC()
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
@ -3197,9 +3275,15 @@ sealed class CC {
is APIGetGroupLink -> "/_get link #$groupId"
is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId"
is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}"
is APIGetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()}"
is APISetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()} ${protoServersStr(servers)}"
is APITestProtoServer -> "/_server test $userId $server"
is ApiGetServerOperators -> "/_operators"
is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}"
is ApiGetUserServers -> "/_servers $userId"
is ApiSetUserServers -> "/_servers $userId ${json.encodeToString(userServers)}"
is ApiValidateServers -> "/_validate_servers $userId ${json.encodeToString(userServers)}"
is ApiGetUsageConditions -> "/_conditions"
is ApiSetConditionsNotified -> "/_conditions_notified ${conditionsId}"
is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}"
is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}"
is APIGetChatItemTTL -> "/_ttl $userId"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
@ -3342,9 +3426,15 @@ sealed class CC {
is APIGetGroupLink -> "apiGetGroupLink"
is APICreateMemberContact -> "apiCreateMemberContact"
is APISendMemberContactInvitation -> "apiSendMemberContactInvitation"
is APIGetUserProtoServers -> "apiGetUserProtoServers"
is APISetUserProtoServers -> "apiSetUserProtoServers"
is APITestProtoServer -> "testProtoServer"
is ApiGetServerOperators -> "apiGetServerOperators"
is ApiSetServerOperators -> "apiSetServerOperators"
is ApiGetUserServers -> "apiGetUserServers"
is ApiSetUserServers -> "apiSetUserServers"
is ApiValidateServers -> "apiValidateServers"
is ApiGetUsageConditions -> "apiGetUsageConditions"
is ApiSetConditionsNotified -> "apiSetConditionsNotified"
is ApiAcceptConditions -> "apiAcceptConditions"
is APISetChatItemTTL -> "apiSetChatItemTTL"
is APIGetChatItemTTL -> "apiGetChatItemTTL"
is APISetNetworkConfig -> "apiSetNetworkConfig"
@ -3459,8 +3549,6 @@ sealed class CC {
companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
fun protoServersStr(servers: List<ServerCfg>) = json.encodeToString(ProtoServersConfig(servers))
}
}
@ -3510,24 +3598,350 @@ enum class ServerProtocol {
}
@Serializable
data class ProtoServersConfig(
val servers: List<ServerCfg>
enum class OperatorTag {
@SerialName("simplex") SimpleX,
@SerialName("flux") Flux,
@SerialName("xyz") XYZ,
@SerialName("demo") Demo
}
data class ServerOperatorInfo(
val description: List<String>,
val website: String,
val logo: ImageResource,
val largeLogo: ImageResource,
val logoDarkMode: ImageResource,
val largeLogoDarkMode: ImageResource
)
val operatorsInfo: Map<OperatorTag, ServerOperatorInfo> = mapOf(
OperatorTag.SimpleX to ServerOperatorInfo(
description = listOf(
"SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.",
"SimpleX Chat Ltd develops the communication software for SimpleX network."
),
website = "https://simplex.chat",
logo = MR.images.decentralized,
largeLogo = MR.images.logo,
logoDarkMode = MR.images.decentralized_light,
largeLogoDarkMode = MR.images.logo_light
),
OperatorTag.Flux to ServerOperatorInfo(
description = listOf(
"Flux is the largest decentralized cloud infrastructure, leveraging a global network of user-operated computational nodes.",
"Flux offers a powerful, scalable, and affordable platform designed to support individuals, businesses, and cutting-edge technologies like AI. With high uptime and worldwide distribution, Flux ensures reliable, accessible cloud computing for all."
),
website = "https://runonflux.com",
logo = MR.images.flux_logo_symbol,
largeLogo = MR.images.flux_logo,
logoDarkMode = MR.images.flux_logo_symbol,
largeLogoDarkMode = MR.images.flux_logo_light
),
OperatorTag.XYZ to ServerOperatorInfo(
description = listOf("XYZ servers"),
website = "XYZ website",
logo = MR.images.shield,
largeLogo = MR.images.logo,
logoDarkMode = MR.images.shield,
largeLogoDarkMode = MR.images.logo_light
),
OperatorTag.Demo to ServerOperatorInfo(
description = listOf("Demo operator"),
website = "Demo website",
logo = MR.images.decentralized,
largeLogo = MR.images.logo,
logoDarkMode = MR.images.decentralized_light,
largeLogoDarkMode = MR.images.logo_light
)
)
@Serializable
data class UserProtocolServers(
val serverProtocol: ServerProtocol,
val protoServers: List<ServerCfg>,
val presetServers: List<ServerCfg>,
data class UsageConditionsDetail(
val conditionsId: Long,
val conditionsCommit: String,
val notifiedAt: Instant?,
val createdAt: Instant
) {
companion object {
val sampleData = UsageConditionsDetail(
conditionsId = 1,
conditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c",
notifiedAt = null,
createdAt = Clock.System.now()
)
}
}
@Serializable
sealed class UsageConditionsAction {
@Serializable @SerialName("review") data class Review(val operators: List<ServerOperator>, val deadline: Instant?, val showNotice: Boolean) : UsageConditionsAction()
@Serializable @SerialName("accepted") data class Accepted(val operators: List<ServerOperator>) : UsageConditionsAction()
val shouldShowNotice: Boolean
get() = when (this) {
is Review -> showNotice
else -> false
}
}
@Serializable
data class ServerOperatorConditionsDetail(
val serverOperators: List<ServerOperator>,
val currentConditions: UsageConditionsDetail,
val conditionsAction: UsageConditionsAction?
) {
companion object {
val empty = ServerOperatorConditionsDetail(
serverOperators = emptyList(),
currentConditions = UsageConditionsDetail(conditionsId = 0, conditionsCommit = "empty", notifiedAt = null, createdAt = Clock.System.now()),
conditionsAction = null
)
}
}
@Serializable()
sealed class ConditionsAcceptance {
@Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?) : ConditionsAcceptance()
@Serializable @SerialName("required") data class Required(val deadline: Instant?) : ConditionsAcceptance()
val conditionsAccepted: Boolean
get() = when (this) {
is Accepted -> true
is Required -> false
}
val usageAllowed: Boolean
get() = when (this) {
is Accepted -> true
is Required -> this.deadline != null
}
}
@Serializable
data class ServerOperator(
val operatorId: Long,
val operatorTag: OperatorTag?,
val tradeName: String,
val legalName: String?,
val serverDomains: List<String>,
val conditionsAcceptance: ConditionsAcceptance,
val enabled: Boolean,
val smpRoles: ServerRoles,
val xftpRoles: ServerRoles,
) {
companion object {
val dummyOperatorInfo = ServerOperatorInfo(
description = listOf("Default"),
website = "Default",
logo = MR.images.decentralized,
largeLogo = MR.images.logo,
logoDarkMode = MR.images.decentralized_light,
largeLogoDarkMode = MR.images.logo_light
)
val sampleData1 = ServerOperator(
operatorId = 1,
operatorTag = OperatorTag.SimpleX,
tradeName = "SimpleX Chat",
legalName = "SimpleX Chat Ltd",
serverDomains = listOf("simplex.im"),
conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null),
enabled = true,
smpRoles = ServerRoles(storage = true, proxy = true),
xftpRoles = ServerRoles(storage = true, proxy = true)
)
val sampleData2 = ServerOperator(
operatorId = 2,
operatorTag = OperatorTag.XYZ,
tradeName = "XYZ",
legalName = null,
serverDomains = listOf("xyz.com"),
conditionsAcceptance = ConditionsAcceptance.Required(deadline = null),
enabled = false,
smpRoles = ServerRoles(storage = false, proxy = true),
xftpRoles = ServerRoles(storage = false, proxy = true)
)
val sampleData3 = ServerOperator(
operatorId = 3,
operatorTag = OperatorTag.Demo,
tradeName = "Demo",
legalName = null,
serverDomains = listOf("demo.com"),
conditionsAcceptance = ConditionsAcceptance.Required(deadline = null),
enabled = false,
smpRoles = ServerRoles(storage = true, proxy = false),
xftpRoles = ServerRoles(storage = true, proxy = false)
)
}
val id: Long
get() = operatorId
override fun equals(other: Any?): Boolean {
if (other !is ServerOperator) return false
return other.operatorId == this.operatorId &&
other.operatorTag == this.operatorTag &&
other.tradeName == this.tradeName &&
other.legalName == this.legalName &&
other.serverDomains == this.serverDomains &&
other.conditionsAcceptance == this.conditionsAcceptance &&
other.enabled == this.enabled &&
other.smpRoles == this.smpRoles &&
other.xftpRoles == this.xftpRoles
}
override fun hashCode(): Int {
var result = operatorId.hashCode()
result = 31 * result + (operatorTag?.hashCode() ?: 0)
result = 31 * result + tradeName.hashCode()
result = 31 * result + (legalName?.hashCode() ?: 0)
result = 31 * result + serverDomains.hashCode()
result = 31 * result + conditionsAcceptance.hashCode()
result = 31 * result + enabled.hashCode()
result = 31 * result + smpRoles.hashCode()
result = 31 * result + xftpRoles.hashCode()
return result
}
val legalName_: String
get() = legalName ?: tradeName
val info: ServerOperatorInfo get() {
return if (this.operatorTag != null) {
operatorsInfo[this.operatorTag] ?: dummyOperatorInfo
} else {
dummyOperatorInfo
}
}
val logo: ImageResource
@Composable
get() {
return if (isInDarkTheme()) info.logoDarkMode else info.logo
}
val largeLogo: ImageResource
@Composable
get() {
return if (isInDarkTheme()) info.largeLogoDarkMode else info.largeLogo
}
}
@Serializable
data class ServerRoles(
val storage: Boolean,
val proxy: Boolean
)
@Serializable
data class ServerCfg(
data class UserOperatorServers(
val operator: ServerOperator?,
val smpServers: List<UserServer>,
val xftpServers: List<UserServer>
) {
val id: String
get() = operator?.operatorId?.toString() ?: "nil operator"
val operator_: ServerOperator
get() = operator ?: ServerOperator(
operatorId = 0,
operatorTag = null,
tradeName = "",
legalName = null,
serverDomains = emptyList(),
conditionsAcceptance = ConditionsAcceptance.Accepted(null),
enabled = false,
smpRoles = ServerRoles(storage = true, proxy = true),
xftpRoles = ServerRoles(storage = true, proxy = true)
)
companion object {
val sampleData1 = UserOperatorServers(
operator = ServerOperator.sampleData1,
smpServers = listOf(UserServer.sampleData.preset),
xftpServers = listOf(UserServer.sampleData.xftpPreset)
)
val sampleDataNilOperator = UserOperatorServers(
operator = null,
smpServers = listOf(UserServer.sampleData.preset),
xftpServers = listOf(UserServer.sampleData.xftpPreset)
)
}
}
@Serializable
sealed class UserServersError {
@Serializable @SerialName("noServers") data class NoServers(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
@Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
@Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
@Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError()
val globalError: String?
get() = when (this.protocol_) {
ServerProtocol.SMP -> globalSMPError
ServerProtocol.XFTP -> globalXFTPError
}
private val protocol_: ServerProtocol
get() = when (this) {
is NoServers -> this.protocol
is StorageMissing -> this.protocol
is ProxyMissing -> this.protocol
is DuplicateServer -> this.protocol
}
val globalSMPError: String?
get() = if (this.protocol_ == ServerProtocol.SMP) {
when (this) {
is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured)}" }
?: generalGetString(MR.strings.no_message_servers_configured)
is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_receiving)}" }
?: generalGetString(MR.strings.no_message_servers_configured_for_receiving)
is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_private_routing)}" }
?: generalGetString(MR.strings.no_message_servers_configured_for_private_routing)
else -> null
}
} else {
null
}
val globalXFTPError: String?
get() = if (this.protocol_ == ServerProtocol.XFTP) {
when (this) {
is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured)}" }
?: generalGetString(MR.strings.no_media_servers_configured)
is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_sending)}" }
?: generalGetString(MR.strings.no_media_servers_configured_for_sending)
is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_private_routing)}" }
?: generalGetString(MR.strings.no_media_servers_configured_for_private_routing)
else -> null
}
} else {
null
}
private fun userStr(user: UserRef): String {
return String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName)
}
}
@Serializable
data class UserServer(
val remoteHostId: Long?,
val serverId: Long?,
val server: String,
val preset: Boolean,
val tested: Boolean? = null,
val enabled: Boolean
val enabled: Boolean,
val deleted: Boolean
) {
@Transient
private val createdAt: Date = Date()
@ -3541,35 +3955,51 @@ data class ServerCfg(
get() = server.isBlank()
companion object {
val empty = ServerCfg(remoteHostId = null, server = "", preset = false, tested = null, enabled = false)
val empty = UserServer(remoteHostId = null, serverId = null, server = "", preset = false, tested = null, enabled = false, deleted = false)
class SampleData(
val preset: ServerCfg,
val custom: ServerCfg,
val untested: ServerCfg
val preset: UserServer,
val custom: UserServer,
val untested: UserServer,
val xftpPreset: UserServer
)
val sampleData = SampleData(
preset = ServerCfg(
preset = UserServer(
remoteHostId = null,
serverId = 1,
server = "smp://abcd@smp8.simplex.im",
preset = true,
tested = true,
enabled = true
enabled = true,
deleted = false
),
custom = ServerCfg(
custom = UserServer(
remoteHostId = null,
serverId = 2,
server = "smp://abcd@smp9.simplex.im",
preset = false,
tested = false,
enabled = false
enabled = false,
deleted = false
),
untested = ServerCfg(
untested = UserServer(
remoteHostId = null,
serverId = 3,
server = "smp://abcd@smp10.simplex.im",
preset = false,
tested = null,
enabled = true
enabled = true,
deleted = false
),
xftpPreset = UserServer(
remoteHostId = null,
serverId = 4,
server = "xftp://abcd@xftp8.simplex.im",
preset = true,
tested = true,
enabled = true,
deleted = false
)
)
}
@ -3683,7 +4113,7 @@ data class NetCfg(
val hostMode: HostMode = HostMode.OnionViaSocks,
val requiredHostMode: Boolean = false,
val sessionMode: TransportSessionMode = TransportSessionMode.default,
val smpProxyMode: SMPProxyMode = SMPProxyMode.Unknown,
val smpProxyMode: SMPProxyMode = SMPProxyMode.Always,
val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.AllowProtected,
val smpWebPort: Boolean = false,
val tcpConnectTimeout: Long, // microseconds
@ -4928,8 +5358,11 @@ sealed class CR {
@Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR()
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR()
@Serializable @SerialName("userProtoServers") class UserProtoServers(val user: UserRef, val servers: UserProtocolServers): CR()
@Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
@Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR()
@Serializable @SerialName("userServers") class UserServers(val user: UserRef, val userServers: List<UserOperatorServers>): CR()
@Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List<UserServersError>): CR()
@Serializable @SerialName("usageConditions") class UsageConditions(val usageConditions: UsageConditionsDetail, val conditionsText: String?, val acceptedConditions: UsageConditionsDetail?): CR()
@Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR()
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
@Serializable @SerialName("contactInfo") class ContactInfo(val user: UserRef, val contact: Contact, val connectionStats_: ConnectionStats? = null, val customUserProfile: Profile? = null): CR()
@ -5108,8 +5541,11 @@ sealed class CR {
is ApiChats -> "apiChats"
is ApiChat -> "apiChat"
is ApiChatItemInfo -> "chatItemInfo"
is UserProtoServers -> "userProtoServers"
is ServerTestResult -> "serverTestResult"
is ServerOperatorConditions -> "serverOperatorConditions"
is UserServers -> "userServers"
is UserServersValidation -> "userServersValidation"
is UsageConditions -> "usageConditions"
is ChatItemTTL -> "chatItemTTL"
is NetworkConfig -> "networkConfig"
is ContactInfo -> "contactInfo"
@ -5278,8 +5714,11 @@ sealed class CR {
is ApiChats -> withUser(user, json.encodeToString(chats))
is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}")
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}")
is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}")
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}"
is UserServers -> withUser(user, "userServers: ${json.encodeToString(userServers)}")
is UserServersValidation -> withUser(user, "serverErrors: ${json.encodeToString(serverErrors)}")
is UsageConditions -> "usageConditions: ${json.encodeToString(usageConditions)}\nnacceptedConditions: ${json.encodeToString(acceptedConditions)}"
is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL))
is NetworkConfig -> json.encodeToString(networkConfig)
is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats_)}")

View file

@ -118,6 +118,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
val user = chatController.apiGetActiveUser(null)
chatModel.currentUser.value = user
chatModel.conditions.value = chatController.getServerOperators(null) ?: ServerOperatorConditionsDetail.empty
if (user == null) {
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
chatModel.currentUser.value = null
@ -137,13 +138,12 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
}
} else if (startChat().await()) {
val savedOnboardingStage = appPreferences.onboardingStage.get()
val next = if (appPlatform.isAndroid) {
OnboardingStage.Step4_SetNotificationsMode
} else {
OnboardingStage.OnboardingComplete
}
val newStage = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
next
if (appPlatform.isAndroid) {
OnboardingStage.Step4_SetNotificationsMode
} else {
OnboardingStage.OnboardingComplete
}
} else {
savedOnboardingStage
}

View file

@ -165,7 +165,7 @@ fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) {
if (!chatModel.connectedToRemote()) {
chatModel.localUserCreated.value = true
}
controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators)
controller.startChat(user)
controller.switchUIRemoteHost(null)
close()
@ -204,7 +204,7 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: ()
onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) {
OnboardingStage.Step2_5_SetupDatabasePassphrase
} else {
OnboardingStage.Step4_SetNotificationsMode
OnboardingStage.Step3_ChooseServerOperators
})
} else {
// the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen,

View file

@ -1260,7 +1260,7 @@ fun BoxScope.ChatItemsList(
} else {
null
}
val showAvatar = if (merged is MergedItem.Grouped) shouldShowAvatar(item, listItem.nextItem) else true
val showAvatar = shouldShowAvatar(item, listItem.nextItem)
val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } }
val itemSeparation: ItemSeparation
val prevItemSeparationLargeGap: Boolean

View file

@ -25,19 +25,21 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.AppLock
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.setConditionsNotified
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.chat.item.CIFileViewScope
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.mkValidName
import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.onboarding.*
import chat.simplex.common.views.showInvalidNameAlert
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton
import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
@ -115,10 +117,26 @@ fun ToggleChatListCard() {
@Composable
fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val rhId = chatModel.remoteHostId()
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
val showWhatsNew = shouldShowWhatsNew(chatModel)
val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false
if (showWhatsNew) {
delay(1000L)
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close, updatedConditions = showUpdatedConditions) }
} else if (showUpdatedConditions) {
ModalManager.center.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
LaunchedEffect(Unit) {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
try {
setConditionsNotified(rh = rhId, conditionsId = conditionsId)
} catch (e: Exception) {
Log.d(TAG, "UsageConditionsView setConditionsNotified error: ${e.message}")
}
}
UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId)
}
}
}

View file

@ -48,7 +48,6 @@ import chat.simplex.common.model.localTimestamp
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.ProtocolServersView
import chat.simplex.common.views.usersettings.SettingsPreferenceItem
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@ -540,15 +539,8 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant,
)
)
}
if (summary.known == true) {
SectionItemView(click = {
ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.XFTP, close) }
}) {
Text(generalGetString(MR.strings.open_server_settings_button))
}
if (summary.stats != null || summary.sessions != null) {
SectionDividerSpaced()
}
if (summary.stats != null || summary.sessions != null) {
SectionDividerSpaced()
}
if (summary.stats != null) {
@ -579,12 +571,7 @@ fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, r
)
)
}
if (summary.known == true) {
SectionItemView(click = {
ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.SMP, close) }
}) {
Text(generalGetString(MR.strings.open_server_settings_button))
}
if (summary.stats != null || summary.subs != null || summary.sessions != null) {
SectionDividerSpaced()
}

View file

@ -17,11 +17,21 @@ import dev.icerock.moko.resources.compose.painterResource
import kotlin.math.absoluteValue
@Composable
fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) {
fun AppBarTitle(
title: String,
hostDevice: Pair<Long?, String>? = null,
withPadding: Boolean = true,
bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp,
enableAlphaChanges: Boolean = true
) {
val handler = LocalAppBarHandler.current
val connection = handler?.connection
val connection = if (enableAlphaChanges) handler?.connection else null
LaunchedEffect(title) {
handler?.title?.value = title
if (enableAlphaChanges) {
handler?.title?.value = title
} else {
handler?.connection?.scrollTrackingEnabled = false
}
}
val theme = CurrentColors.collectAsState()
val titleColor = MaterialTheme.appColors.title
@ -54,7 +64,8 @@ fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPad
}
private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) =
if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
if (connection?.scrollTrackingEnabled == false) 1f
else if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx
@Composable

View file

@ -84,6 +84,7 @@ class AppBarHandler(
}
class CollapsingAppBarNestedScrollConnection(): NestedScrollConnection {
var scrollTrackingEnabled = true
var appBarOffset: Float by mutableFloatStateOf(0f)
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {

View file

@ -258,7 +258,8 @@ private fun AppBarCenterAligned(
}
private fun topTitleAlpha(text: Boolean, connection: CollapsingAppBarNestedScrollConnection, alpha: Float = appPrefs.inAppBarsAlpha.get()) =
if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
if (!connection.scrollTrackingEnabled) 0f
else if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, if (text) 1f else alpha)
val AppBarHeight = 56.dp

View file

@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword
import chat.simplex.common.views.newchat.QRCodeScanner
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource

View file

@ -0,0 +1,354 @@
package chat.simplex.common.views.onboarding
import SectionBottomSpacer
import SectionTextFooter
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ServerOperator
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.networkAndServers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
fun ModalData.ChooseServerOperators(
onboarding: Boolean,
close: (() -> Unit) = { ModalManager.fullscreen.closeModals() },
modalManager: ModalManager = ModalManager.fullscreen
) {
LaunchedEffect(Unit) {
prepareChatBeforeFinishingOnboarding()
}
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ModalView({}, showClose = false, endButtons = {
IconButton({ modalManager.showModal { ChooseServerOperatorsInfoView() } }) {
Icon(painterResource(MR.images.ic_info), null, Modifier.size(28.dp), tint = MaterialTheme.colors.primary)
}
}) {
val serverOperators = remember { derivedStateOf { chatModel.conditions.value.serverOperators } }
val selectedOperatorIds = remember { stateGetOrPut("selectedOperatorIds") { serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() } }
val selectedOperators = remember { derivedStateOf { serverOperators.value.filter { selectedOperatorIds.value.contains(it.operatorId) } } }
ColumnWithScrollBar(
Modifier
.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer),
maxIntrinsicSize = true
) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
AppBarTitle(stringResource(MR.strings.onboarding_choose_server_operators))
}
Column((
if (appPlatform.isDesktop) Modifier.width(600.dp).align(Alignment.CenterHorizontally) else Modifier)
.padding(horizontal = DEFAULT_PADDING)
) {
Text(stringResource(MR.strings.onboarding_select_network_operators_to_use))
Spacer(Modifier.height(DEFAULT_PADDING))
}
Spacer(Modifier.weight(1f))
Column((
if (appPlatform.isDesktop) Modifier.width(600.dp).align(Alignment.CenterHorizontally) else Modifier)
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING)
) {
serverOperators.value.forEachIndexed { index, srvOperator ->
OperatorCheckView(srvOperator, selectedOperatorIds)
if (index != serverOperators.value.lastIndex) {
Spacer(Modifier.height(DEFAULT_PADDING))
}
}
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
SectionTextFooter(annotatedStringResource(MR.strings.onboarding_network_operators_configure_via_settings), textAlign = TextAlign.Center)
}
Spacer(Modifier.weight(1f))
val reviewForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted }
val canReviewLater = reviewForOperators.all { it.conditionsAcceptance.usageAllowed }
val currEnabledOperatorIds = serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet()
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
val enabled = selectedOperatorIds.value.isNotEmpty()
when {
reviewForOperators.isNotEmpty() -> ReviewConditionsButton(enabled, onboarding, selectedOperators, selectedOperatorIds, modalManager)
selectedOperatorIds.value != currEnabledOperatorIds && enabled -> SetOperatorsButton(true, onboarding, serverOperators, selectedOperatorIds, close)
else -> ContinueButton(enabled, onboarding, close)
}
if (onboarding && reviewForOperators.isEmpty()) {
TextButtonBelowOnboardingButton(stringResource(MR.strings.operator_conditions_of_use)) {
modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
UsageConditionsView(
currUserServers = remember { mutableStateOf(emptyList()) },
userServers = remember { mutableStateOf(emptyList()) },
close = close,
rhId = null
)
}
}
} else if (onboarding || reviewForOperators.isEmpty()) {
// Reserve space
TextButtonBelowOnboardingButton("", null)
}
if (!onboarding && reviewForOperators.isNotEmpty()) {
ReviewLaterButton(canReviewLater, close)
SectionTextFooter(
annotatedStringResource(MR.strings.onboarding_network_operators_conditions_will_be_accepted) +
AnnotatedString(" ") +
annotatedStringResource(MR.strings.onboarding_network_operators_conditions_you_can_configure),
textAlign = TextAlign.Center
)
SectionBottomSpacer()
}
}
}
}
}
}
@Composable
private fun OperatorCheckView(serverOperator: ServerOperator, selectedOperatorIds: MutableState<Set<Long>>) {
val checked = selectedOperatorIds.value.contains(serverOperator.operatorId)
TextButton({
if (checked) {
selectedOperatorIds.value -= serverOperator.operatorId
} else {
selectedOperatorIds.value += serverOperator.operatorId
}
},
border = BorderStroke(1.dp, color = if (checked) MaterialTheme.colors.primary else MaterialTheme.colors.secondary.copy(alpha = 0.5f)),
shape = RoundedCornerShape(18.dp)
) {
Row(Modifier.padding(DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically) {
Image(painterResource(serverOperator.largeLogo), null, Modifier.height(48.dp))
Spacer(Modifier.width(DEFAULT_PADDING_HALF).weight(1f))
CircleCheckbox(checked)
}
}
}
@Composable
private fun CircleCheckbox(checked: Boolean) {
if (checked) {
Box(contentAlignment = Alignment.Center) {
Icon(
painterResource(MR.images.ic_circle_filled),
null,
Modifier.size(26.dp),
tint = MaterialTheme.colors.primary
)
Icon(
painterResource(MR.images.ic_check_filled),
null,
Modifier.size(20.dp), tint = MaterialTheme.colors.background
)
}
} else {
Icon(
painterResource(MR.images.ic_circle),
null,
Modifier.size(26.dp),
tint = MaterialTheme.colors.secondary.copy(alpha = 0.5f)
)
}
}
@Composable
private fun ReviewConditionsButton(
enabled: Boolean,
onboarding: Boolean,
selectedOperators: State<List<ServerOperator>>,
selectedOperatorIds: State<Set<Long>>,
modalManager: ModalManager
) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
labelId = MR.strings.operator_review_conditions,
onboarding = null,
enabled = enabled,
onclick = {
modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
ReviewConditionsView(onboarding, selectedOperators, selectedOperatorIds, close)
}
}
)
}
@Composable
private fun SetOperatorsButton(enabled: Boolean, onboarding: Boolean, serverOperators: State<List<ServerOperator>>, selectedOperatorIds: State<Set<Long>>, close: () -> Unit) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
labelId = MR.strings.onboarding_network_operators_update,
onboarding = null,
enabled = enabled,
onclick = {
withBGApi {
val enabledOperators = enabledOperators(serverOperators.value, selectedOperatorIds.value)
if (enabledOperators != null) {
val r = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators)
if (r != null) {
chatModel.conditions.value = r
}
continueToNextStep(onboarding, close)
}
}
}
)
}
@Composable
private fun ContinueButton(enabled: Boolean, onboarding: Boolean, close: () -> Unit) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
labelId = MR.strings.onboarding_network_operators_continue,
onboarding = null,
enabled = enabled,
onclick = {
continueToNextStep(onboarding, close)
}
)
}
@Composable
private fun ReviewLaterButton(enabled: Boolean, close: () -> Unit) {
TextButtonBelowOnboardingButton(
stringResource(MR.strings.onboarding_network_operators_review_later),
onClick = if (!enabled) null else {{ continueToNextStep(false, close) }}
)
}
@Composable
private fun ReviewConditionsView(
onboarding: Boolean,
selectedOperators: State<List<ServerOperator>>,
selectedOperatorIds: State<Set<Long>>,
close: () -> Unit
) {
// remembering both since we don't want to reload the view after the user accepts conditions
val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } }
val acceptForOperators = remember { selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } }
ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), withPadding = false, enableAlphaChanges = false)
if (operatorsWithConditionsAccepted.isNotEmpty()) {
ReadableText(MR.strings.operator_conditions_accepted_for_some, args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ })
ReadableText(MR.strings.operator_same_conditions_will_apply_to_operators, args = acceptForOperators.joinToString(", ") { it.legalName_ })
} else {
ReadableText(MR.strings.operator_conditions_will_be_accepted_for_some, args = acceptForOperators.joinToString(", ") { it.legalName_ })
}
Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF)) {
ConditionsTextView(chatModel.remoteHostId())
}
Column(Modifier.padding(top = DEFAULT_PADDING).widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
AcceptConditionsButton(onboarding, selectedOperators, selectedOperatorIds, close)
// Reserve space
TextButtonBelowOnboardingButton("", null)
}
}
}
@Composable
private fun AcceptConditionsButton(
onboarding: Boolean,
selectedOperators: State<List<ServerOperator>>,
selectedOperatorIds: State<Set<Long>>,
close: () -> Unit
) {
fun continueOnAccept() {
if (appPlatform.isDesktop || !onboarding) {
if (onboarding) { close() }
continueToNextStep(onboarding, close)
} else {
continueToSetNotificationsAfterAccept()
}
}
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier,
labelId = MR.strings.accept_conditions,
onboarding = null,
onclick = {
withBGApi {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
val acceptForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted }
val operatorIds = acceptForOperators.map { it.operatorId }
val r = chatController.acceptConditions(chatModel.remoteHostId(), conditionsId = conditionsId, operatorIds = operatorIds)
if (r != null) {
chatModel.conditions.value = r
val enabledOperators = enabledOperators(r.serverOperators, selectedOperatorIds.value)
if (enabledOperators != null) {
val r2 = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators)
if (r2 != null) {
chatModel.conditions.value = r2
continueOnAccept()
}
} else {
continueOnAccept()
}
}
}
}
)
}
private fun continueToNextStep(onboarding: Boolean, close: () -> Unit) {
if (onboarding) {
appPrefs.onboardingStage.set(if (appPlatform.isAndroid) OnboardingStage.Step4_SetNotificationsMode else OnboardingStage.OnboardingComplete)
} else {
close()
}
}
private fun continueToSetNotificationsAfterAccept() {
appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
ModalManager.fullscreen.showModalCloseable(showClose = false) { SetNotificationsMode(chatModel) }
}
private fun enabledOperators(operators: List<ServerOperator>, selectedOperatorIds: Set<Long>): List<ServerOperator>? {
val ops = ArrayList(operators)
if (ops.isNotEmpty()) {
for (i in ops.indices) {
val op = ops[i]
ops[i] = op.copy(enabled = selectedOperatorIds.contains(op.operatorId))
}
val haveSMPStorage = ops.any { it.enabled && it.smpRoles.storage }
val haveSMPProxy = ops.any { it.enabled && it.smpRoles.proxy }
val haveXFTPStorage = ops.any { it.enabled && it.xftpRoles.storage }
val haveXFTPProxy = ops.any { it.enabled && it.xftpRoles.proxy }
val firstEnabledIndex = ops.indexOfFirst { it.enabled }
if (haveSMPStorage && haveSMPProxy && haveXFTPStorage && haveXFTPProxy) {
return ops
} else if (firstEnabledIndex != -1) {
var op = ops[firstEnabledIndex]
if (!haveSMPStorage) op = op.copy(smpRoles = op.smpRoles.copy(storage = true))
if (!haveSMPProxy) op = op.copy(smpRoles = op.smpRoles.copy(proxy = true))
if (!haveXFTPStorage) op = op.copy(xftpRoles = op.xftpRoles.copy(storage = true))
if (!haveXFTPProxy) op = op.copy(xftpRoles = op.xftpRoles.copy(proxy = true))
ops[firstEnabledIndex] = op
return ops
} else { // Shouldn't happen - view doesn't let to proceed if no operators are enabled
return null
}
} else {
return null
}
}
@Composable
private fun ChooseServerOperatorsInfoView() {
ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.onboarding_network_operators), withPadding = false)
ReadableText(stringResource(MR.strings.onboarding_network_operators_app_will_use_different_operators))
ReadableText(stringResource(MR.strings.onboarding_network_operators_app_will_use_for_routing))
SectionBottomSpacer()
}
}

View file

@ -48,8 +48,8 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>?
}
@Composable
fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current) {
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style)
fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current, args: Any? = null) {
Text(annotatedStringResource(stringResId, args), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style)
}
@Composable

View file

@ -5,6 +5,7 @@ enum class OnboardingStage {
Step2_CreateProfile,
LinkAMobile,
Step2_5_SetupDatabasePassphrase,
Step3_ChooseServerOperators,
Step3_CreateSimpleXAddress,
Step4_SetNotificationsMode,
OnboardingComplete

View file

@ -16,8 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.NotificationsMode
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.changeNotificationsMode
@ -26,7 +25,7 @@ import chat.simplex.res.MR
@Composable
fun SetNotificationsMode(m: ChatModel) {
LaunchedEffect(Unit) {
prepareChatBeforeNotificationsSetup(m)
prepareChatBeforeFinishingOnboarding()
}
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
@ -57,6 +56,7 @@ fun SetNotificationsMode(m: ChatModel) {
onboarding = OnboardingStage.OnboardingComplete,
onclick = {
changeNotificationsMode(currentMode.value, m)
ModalManager.fullscreen.closeModals()
}
)
// Reserve space
@ -99,7 +99,7 @@ fun <T> SelectableCard(currentValue: State<T>, newValue: T, title: String, descr
Spacer(Modifier.height(14.dp))
}
private fun prepareChatBeforeNotificationsSetup(chatModel: ChatModel) {
fun prepareChatBeforeFinishingOnboarding() {
// No visible users but may have hidden. In this case chat should be started anyway because it's stopped on this stage with hidden users
if (chatModel.users.any { u -> !u.user.hidden }) return
withBGApi {

View file

@ -17,7 +17,6 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.database.*
@ -36,7 +35,7 @@ fun SetupDatabasePassphrase(m: ChatModel) {
val confirmNewKey = rememberSaveable { mutableStateOf("") }
fun nextStep() {
if (appPlatform.isAndroid || chatModel.currentUser.value != null) {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators)
} else {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile)
}

View file

@ -164,14 +164,15 @@ fun OnboardingActionButton(
@Composable
fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?) {
val state = getKeyboardState()
val enabled = onClick != null
val topPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING)
val bottomPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING * 2)
if ((appPlatform.isAndroid && state.value == KeyboardState.Closed) || topPadding > 0.dp) {
TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = onClick != null) {
TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = enabled) {
Text(
text,
Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF, bottom = 5.dp),
color = MaterialTheme.colors.primary,
color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center
)

View file

@ -8,7 +8,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalUriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@ -17,17 +16,32 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.setConditionsNotified
import chat.simplex.common.model.ServerOperator.Companion.dummyOperatorInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
@Composable
fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Boolean = false, close: () -> Unit) {
val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) }
val rhId = chatModel.remoteHostId()
if (updatedConditions) {
LaunchedEffect(Unit) {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
try {
setConditionsNotified(rh = rhId, conditionsId = conditionsId)
} catch (e: Exception) {
Log.d(TAG, "WhatsNewView setConditionsNotified error: ${e.message}")
}
}
}
@Composable
fun featureDescription(icon: ImageResource?, titleId: StringResource, descrId: StringResource?, link: String?, subfeatures: List<Pair<ImageResource, StringResource>>) {
@ -124,9 +138,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
) {
AppBarTitle(String.format(generalGetString(MR.strings.new_in_version), v.version), withPadding = false, bottomPadding = DEFAULT_PADDING)
val modalManager = if (viaSettings) ModalManager.start else ModalManager.center
v.features.forEach { feature ->
if (feature.show) {
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures)
when (feature) {
is VersionFeature.FeatureDescription -> {
if (feature.show) {
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures)
}
}
is VersionFeature.FeatureView -> {
feature.view(modalManager)
}
}
}
@ -134,6 +157,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
ReadMoreButton(v.post)
}
if (updatedConditions) {
Text(
stringResource(MR.strings.view_updated_conditions),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable {
modalManager.showModalCloseable {
close -> UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId)
}
}
)
}
if (!viaSettings) {
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(
@ -141,7 +176,9 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
) {
Text(
generalGetString(MR.strings.ok),
modifier = Modifier.clickable(onClick = close),
modifier = Modifier.clickable(onClick = {
close()
}),
style = MaterialTheme.typography.h3,
color = MaterialTheme.colors.primary
)
@ -166,18 +203,26 @@ fun ReadMoreButton(url: String) {
}
}
private data class FeatureDescription(
val icon: ImageResource?,
val titleId: StringResource,
val descrId: StringResource?,
var subfeatures: List<Pair<ImageResource, StringResource>> = listOf(),
val link: String? = null,
val show: Boolean = true
)
private sealed class VersionFeature {
class FeatureDescription(
val icon: ImageResource?,
val titleId: StringResource,
val descrId: StringResource?,
var subfeatures: List<Pair<ImageResource, StringResource>> = listOf(),
val link: String? = null,
val show: Boolean = true
): VersionFeature()
class FeatureView(
val icon: ImageResource?,
val titleId: StringResource,
val view: @Composable (modalManager: ModalManager) -> Unit
): VersionFeature()
}
private data class VersionDescription(
val version: String,
val features: List<FeatureDescription>,
val features: List<VersionFeature>,
val post: String? = null,
)
@ -186,18 +231,18 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v4.2",
post = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_verified_user,
titleId = MR.strings.v4_2_security_assessment,
descrId = MR.strings.v4_2_security_assessment_desc,
link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_group,
titleId = MR.strings.v4_2_group_links,
descrId = MR.strings.v4_2_group_links_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_check,
titleId = MR.strings.v4_2_auto_accept_contact_requests,
descrId = MR.strings.v4_2_auto_accept_contact_requests_desc
@ -208,22 +253,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v4.3",
post = "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_mic,
titleId = MR.strings.v4_3_voice_messages,
descrId = MR.strings.v4_3_voice_messages_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_delete_forever,
titleId = MR.strings.v4_3_irreversible_message_deletion,
descrId = MR.strings.v4_3_irreversible_message_deletion_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_wifi_tethering,
titleId = MR.strings.v4_3_improved_server_configuration,
descrId = MR.strings.v4_3_improved_server_configuration_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_visibility_off,
titleId = MR.strings.v4_3_improved_privacy_and_security,
descrId = MR.strings.v4_3_improved_privacy_and_security_desc
@ -234,22 +279,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v4.4",
post = "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_timer,
titleId = MR.strings.v4_4_disappearing_messages,
descrId = MR.strings.v4_4_disappearing_messages_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_pending,
titleId = MR.strings.v4_4_live_messages,
descrId = MR.strings.v4_4_live_messages_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_verified_user,
titleId = MR.strings.v4_4_verify_connection_security,
descrId = MR.strings.v4_4_verify_connection_security_desc
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v4_4_french_interface,
descrId = MR.strings.v4_4_french_interface_descr
@ -260,33 +305,33 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v4.5",
post = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_manage_accounts,
titleId = MR.strings.v4_5_multiple_chat_profiles,
descrId = MR.strings.v4_5_multiple_chat_profiles_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_edit_note,
titleId = MR.strings.v4_5_message_draft,
descrId = MR.strings.v4_5_message_draft_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_safety_divider,
titleId = MR.strings.v4_5_transport_isolation,
descrId = MR.strings.v4_5_transport_isolation_descr,
link = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_task,
titleId = MR.strings.v4_5_private_filenames,
descrId = MR.strings.v4_5_private_filenames_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_battery_2_bar,
titleId = MR.strings.v4_5_reduced_battery_usage,
descrId = MR.strings.v4_5_reduced_battery_usage_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v4_5_italian_interface,
descrId = MR.strings.v4_5_italian_interface_descr,
@ -297,32 +342,32 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v4.6",
post = "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_lock,
titleId = MR.strings.v4_6_hidden_chat_profiles,
descrId = MR.strings.v4_6_hidden_chat_profiles_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_flag,
titleId = MR.strings.v4_6_group_moderation,
descrId = MR.strings.v4_6_group_moderation_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_maps_ugc,
titleId = MR.strings.v4_6_group_welcome_message,
descrId = MR.strings.v4_6_group_welcome_message_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_call,
titleId = MR.strings.v4_6_audio_video_calls,
descrId = MR.strings.v4_6_audio_video_calls_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_battery_3_bar,
titleId = MR.strings.v4_6_reduced_battery_usage,
descrId = MR.strings.v4_6_reduced_battery_usage_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v4_6_chinese_spanish_interface,
descrId = MR.strings.v4_6_chinese_spanish_interface_descr,
@ -333,17 +378,17 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.0",
post = "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_upload_file,
titleId = MR.strings.v5_0_large_files_support,
descrId = MR.strings.v5_0_large_files_support_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_lock,
titleId = MR.strings.v5_0_app_passcode,
descrId = MR.strings.v5_0_app_passcode_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_0_polish_interface,
descrId = MR.strings.v5_0_polish_interface_descr,
@ -354,27 +399,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.1",
post = "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_add_reaction,
titleId = MR.strings.v5_1_message_reactions,
descrId = MR.strings.v5_1_message_reactions_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_chat,
titleId = MR.strings.v5_1_better_messages,
descrId = MR.strings.v5_1_better_messages_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_light_mode,
titleId = MR.strings.v5_1_custom_themes,
descrId = MR.strings.v5_1_custom_themes_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_lock,
titleId = MR.strings.v5_1_self_destruct_passcode,
descrId = MR.strings.v5_1_self_destruct_passcode_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_1_japanese_portuguese_interface,
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
@ -385,27 +430,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.2",
post = "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_check,
titleId = MR.strings.v5_2_message_delivery_receipts,
descrId = MR.strings.v5_2_message_delivery_receipts_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_star,
titleId = MR.strings.v5_2_favourites_filter,
descrId = MR.strings.v5_2_favourites_filter_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_sync_problem,
titleId = MR.strings.v5_2_fix_encryption,
descrId = MR.strings.v5_2_fix_encryption_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_timer,
titleId = MR.strings.v5_2_disappear_one_message,
descrId = MR.strings.v5_2_disappear_one_message_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_redeem,
titleId = MR.strings.v5_2_more_things,
descrId = MR.strings.v5_2_more_things_descr
@ -416,29 +461,29 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.3",
post = "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_desktop,
titleId = MR.strings.v5_3_new_desktop_app,
descrId = MR.strings.v5_3_new_desktop_app_descr,
link = "https://simplex.chat/downloads/"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_lock,
titleId = MR.strings.v5_3_encrypt_local_files,
descrId = MR.strings.v5_3_encrypt_local_files_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_search,
titleId = MR.strings.v5_3_discover_join_groups,
descrId = MR.strings.v5_3_discover_join_groups_descr,
link = "simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_theater_comedy,
titleId = MR.strings.v5_3_simpler_incognito_mode,
descrId = MR.strings.v5_3_simpler_incognito_mode_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_3_new_interface_languages,
descrId = MR.strings.v5_3_new_interface_languages_descr,
@ -449,27 +494,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.4",
post = "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_desktop,
titleId = MR.strings.v5_4_link_mobile_desktop,
descrId = MR.strings.v5_4_link_mobile_desktop_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_group,
titleId = MR.strings.v5_4_better_groups,
descrId = MR.strings.v5_4_better_groups_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_theater_comedy,
titleId = MR.strings.v5_4_incognito_groups,
descrId = MR.strings.v5_4_incognito_groups_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_back_hand,
titleId = MR.strings.v5_4_block_group_members,
descrId = MR.strings.v5_4_block_group_members_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_redeem,
titleId = MR.strings.v5_2_more_things,
descrId = MR.strings.v5_4_more_things_descr
@ -480,28 +525,28 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.5",
post = "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_folder_pen,
titleId = MR.strings.v5_5_private_notes,
descrId = MR.strings.v5_5_private_notes_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_link,
titleId = MR.strings.v5_5_simpler_connect_ui,
descrId = MR.strings.v5_5_simpler_connect_ui_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_forum,
titleId = MR.strings.v5_5_join_group_conversation,
descrId = MR.strings.v5_5_join_group_conversation_descr,
link = "simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_battery_3_bar,
titleId = MR.strings.v5_5_message_delivery,
descrId = MR.strings.v5_5_message_delivery_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_5_new_interface_languages,
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
@ -512,22 +557,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.6",
post = "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_vpn_key_filled,
titleId = MR.strings.v5_6_quantum_resistant_encryption,
descrId = MR.strings.v5_6_quantum_resistant_encryption_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_ios_share,
titleId = MR.strings.v5_6_app_data_migration,
descrId = MR.strings.v5_6_app_data_migration_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_call,
titleId = MR.strings.v5_6_picture_in_picture_calls,
descrId = MR.strings.v5_6_picture_in_picture_calls_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_back_hand,
titleId = MR.strings.v5_6_safer_groups,
descrId = MR.strings.v5_6_safer_groups_descr
@ -538,32 +583,32 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.7",
post = "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_vpn_key_filled,
titleId = MR.strings.v5_6_quantum_resistant_encryption,
descrId = MR.strings.v5_7_quantum_resistant_encryption_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_forward,
titleId = MR.strings.v5_7_forward,
descrId = MR.strings.v5_7_forward_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_music_note,
titleId = MR.strings.v5_7_call_sounds,
descrId = MR.strings.v5_7_call_sounds_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_account_box,
titleId = MR.strings.v5_7_shape_profile_images,
descrId = MR.strings.v5_7_shape_profile_images_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_wifi_tethering,
titleId = MR.strings.v5_7_network,
descrId = MR.strings.v5_7_network_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_7_new_interface_languages,
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
@ -574,27 +619,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v5.8",
post = "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_settings_ethernet,
titleId = MR.strings.v5_8_private_routing,
descrId = MR.strings.v5_8_private_routing_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_palette,
titleId = MR.strings.v5_8_chat_themes,
descrId = MR.strings.v5_8_chat_themes_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_security,
titleId = MR.strings.v5_8_safe_files,
descrId = MR.strings.v5_8_safe_files_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_battery_3_bar,
titleId = MR.strings.v5_8_message_delivery,
descrId = MR.strings.v5_8_message_delivery_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_translate,
titleId = MR.strings.v5_8_persian_ui,
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate
@ -605,7 +650,7 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v6.0",
post = "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = null,
titleId = MR.strings.v6_0_new_chat_experience,
descrId = null,
@ -616,7 +661,7 @@ private val versionDescriptions: List<VersionDescription> = listOf(
MR.images.ic_match_case to MR.strings.v6_0_increase_font_size
)
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = null,
titleId = MR.strings.v6_0_new_media_options,
descrId = null,
@ -625,23 +670,23 @@ private val versionDescriptions: List<VersionDescription> = listOf(
MR.images.ic_blur_on to MR.strings.v6_0_privacy_blur,
)
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_toast,
titleId = MR.strings.v6_0_reachable_chat_toolbar,
descrId = MR.strings.v6_0_reachable_chat_toolbar_descr,
show = appPlatform.isAndroid
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_settings_ethernet,
titleId = MR.strings.v5_8_private_routing,
descrId = MR.strings.v6_0_private_routing_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_wifi_tethering,
titleId = MR.strings.v6_0_connection_servers_status,
descrId = MR.strings.v6_0_connection_servers_status_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_upgrade,
titleId = MR.strings.v6_0_upgrade_app,
descrId = MR.strings.v6_0_upgrade_app_descr,
@ -653,18 +698,18 @@ private val versionDescriptions: List<VersionDescription> = listOf(
version = "v6.1",
post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html",
features = listOf(
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_verified_user,
titleId = MR.strings.v6_1_better_security,
descrId = MR.strings.v6_1_better_security_descr,
link = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = MR.images.ic_videocam,
titleId = MR.strings.v6_1_better_calls,
descrId = MR.strings.v6_1_better_calls_descr
),
FeatureDescription(
VersionFeature.FeatureDescription(
icon = null,
titleId = MR.strings.v6_1_better_user_experience,
descrId = null,
@ -678,6 +723,39 @@ private val versionDescriptions: List<VersionDescription> = listOf(
),
),
),
VersionDescription(
version = "v6.2-beta.1",
post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html",
features = listOf(
VersionFeature.FeatureView(
icon = null,
titleId = MR.strings.v6_2_network_decentralization,
view = { modalManager ->
Column {
val src = (operatorsInfo[OperatorTag.Flux] ?: dummyOperatorInfo).largeLogo
Image(painterResource(src), null, modifier = Modifier.height(48.dp))
Text(stringResource(MR.strings.v6_2_network_decentralization_descr), modifier = Modifier.padding(top = 8.dp))
Row {
Text(
stringResource(MR.strings.v6_2_network_decentralization_enable_flux),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable {
modalManager.showModalCloseable { close -> ChooseServerOperators(onboarding = false, close, modalManager) }
}
)
Text(" ")
Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux_reason))
}
}
}
),
VersionFeature.FeatureDescription(
icon = MR.images.ic_chat,
titleId = MR.strings.v6_2_improved_chat_navigation,
descrId = MR.strings.v6_2_improved_chat_navigation_descr
),
),
)
)
private val lastVersion = versionDescriptions.last().version
@ -700,7 +778,8 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
@Composable
fun PreviewWhatsNewView() {
SimpleXTheme {
WhatsNewView(
val data = remember { ModalData() }
data.WhatsNewView(
viaSettings = true,
close = {}
)

View file

@ -35,6 +35,7 @@ import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCode
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.validPort
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource

View file

@ -1,383 +0,0 @@
package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.appPlatform
import chat.simplex.res.MR
@Composable
fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) {
var presetServers by remember(rhId) { mutableStateOf(emptyList<ServerCfg>()) }
var servers by remember { stateGetOrPut("servers") { emptyList<ServerCfg>() } }
var serversAlreadyLoaded by remember { stateGetOrPut("serversAlreadyLoaded") { false } }
val currServers = remember(rhId) { mutableStateOf(servers) }
val testing = rememberSaveable(rhId) { mutableStateOf(false) }
val serversUnchanged = remember(servers) { derivedStateOf { servers == currServers.value || testing.value } }
val allServersDisabled = remember { derivedStateOf { servers.none { it.enabled } } }
val saveDisabled = remember(servers) {
derivedStateOf {
servers.isEmpty() ||
servers == currServers.value ||
testing.value ||
servers.none { srv ->
val address = parseServerAddress(srv.server)
address != null && uniqueAddress(srv, address, servers)
} ||
allServersDisabled.value
}
}
KeyChangeEffect(rhId) {
servers = emptyList()
serversAlreadyLoaded = false
}
LaunchedEffect(rhId) {
withApi {
val res = m.controller.getUserProtoServers(rhId, serverProtocol)
if (res != null) {
currServers.value = res.protoServers
presetServers = res.presetServers
if (servers.isEmpty() && !serversAlreadyLoaded) {
servers = currServers.value
serversAlreadyLoaded = true
}
}
}
}
val testServersJob = CancellableOnGoneJob()
fun showServer(server: ServerCfg) {
ModalManager.start.showModalCloseable(true) { close ->
var old by remember { mutableStateOf(server) }
val index = servers.indexOf(old)
ProtocolServerView(
m,
old,
serverProtocol,
onUpdate = { updated ->
val newServers = ArrayList(servers)
newServers.removeAt(index)
newServers.add(index, updated)
old = updated
servers = newServers
},
onDelete = {
val newServers = ArrayList(servers)
newServers.removeAt(index)
servers = newServers
close()
})
}
}
ModalView(
close = {
if (saveDisabled.value) close()
else showUnsavedChangesAlert({ saveServers(rhId, serverProtocol, currServers, servers, m, close) }, close)
},
) {
ProtocolServersLayout(
serverProtocol,
testing = testing.value,
servers = servers,
serversUnchanged = serversUnchanged.value,
saveDisabled = saveDisabled.value,
allServersDisabled = allServersDisabled.value,
m.currentUser.value,
addServer = {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.smp_servers_add),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
servers = servers + ServerCfg.empty
// No saving until something will be changed on the next screen to prevent blank servers on the list
showServer(servers.last())
}) {
Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
if (appPlatform.isAndroid) {
SectionItemView({
AlertManager.shared.hideAlert()
ModalManager.start.showModalCloseable { close ->
ScanProtocolServer(rhId) {
close()
servers = servers + it
}
}
}
) {
Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
val hasAllPresets = hasAllPresets(presetServers, servers, m)
if (!hasAllPresets) {
SectionItemView({
AlertManager.shared.hideAlert()
servers = (servers + addAllPresets(rhId, presetServers, servers, m)).sortedByDescending { it.preset }
}) {
Text(stringResource(MR.strings.smp_servers_preset_add), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
}
)
},
testServers = {
testServersJob.value = withLongRunningApi {
testServers(testing, servers, m) {
servers = it
}
}
},
resetServers = {
servers = currServers.value
},
saveSMPServers = {
saveServers(rhId, serverProtocol, currServers, servers, m)
},
showServer = ::showServer,
)
if (testing.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.5.dp
)
}
}
}
}
@Composable
private fun ProtocolServersLayout(
serverProtocol: ServerProtocol,
testing: Boolean,
servers: List<ServerCfg>,
serversUnchanged: Boolean,
saveDisabled: Boolean,
allServersDisabled: Boolean,
currentUser: User?,
addServer: () -> Unit,
testServers: () -> Unit,
resetServers: () -> Unit,
saveSMPServers: () -> Unit,
showServer: (ServerCfg) -> Unit,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.your_SMP_servers else MR.strings.your_XFTP_servers))
val configuredServers = servers.filter { it.preset || it.enabled }
val otherServers = servers.filter { !(it.preset || it.enabled) }
if (configuredServers.isNotEmpty()) {
SectionView(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.smp_servers_configured else MR.strings.xftp_servers_configured).uppercase()) {
for (srv in configuredServers) {
SectionItemView({ showServer(srv) }, disabled = testing) {
ProtocolServerView(serverProtocol, srv, servers, testing)
}
}
}
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.smp_servers_per_user) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
}
if (otherServers.isNotEmpty()) {
SectionView(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.smp_servers_other else MR.strings.xftp_servers_other).uppercase()) {
for (srv in otherServers.filter { !(it.preset || it.enabled) }) {
SectionItemView({ showServer(srv) }, disabled = testing) {
ProtocolServerView(serverProtocol, srv, servers, testing)
}
}
}
}
SectionView {
SettingsActionItem(
painterResource(MR.images.ic_add),
stringResource(MR.strings.smp_servers_add),
addServer,
disabled = testing,
textColor = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
iconColor = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
}
SectionView {
SectionItemView(resetServers, disabled = serversUnchanged) {
Text(stringResource(MR.strings.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
val testServersDisabled = testing || allServersDisabled
SectionItemView(testServers, disabled = testServersDisabled) {
Text(stringResource(MR.strings.smp_servers_test_servers), color = if (!testServersDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
SectionItemView(saveSMPServers, disabled = saveDisabled) {
Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
}
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
HowToButton()
}
SectionBottomSpacer()
}
}
@Composable
private fun ProtocolServerView(serverProtocol: ServerProtocol, srv: ServerCfg, servers: List<ServerCfg>, disabled: Boolean) {
val address = parseServerAddress(srv.server)
when {
address == null || !address.valid || address.serverProtocol != serverProtocol || !uniqueAddress(srv, address, servers) -> InvalidServer()
!srv.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary)
else -> ShowTestStatus(srv)
}
Spacer(Modifier.padding(horizontal = 4.dp))
val text = address?.hostnames?.firstOrNull() ?: srv.server
if (srv.enabled) {
Text(text, color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground, maxLines = 1)
} else {
Text(text, maxLines = 1, color = MaterialTheme.colors.secondary)
}
}
@Composable
private fun HowToButton() {
val uriHandler = LocalUriHandler.current
SettingsActionItem(
painterResource(MR.images.ic_open_in_new),
stringResource(MR.strings.how_to_use_your_servers),
{ uriHandler.openUriCatching("https://simplex.chat/docs/server.html") },
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary
)
}
@Composable
fun InvalidServer() {
Icon(painterResource(MR.images.ic_error), null, tint = MaterialTheme.colors.error)
}
private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List<ServerCfg>): Boolean = servers.all { srv ->
address.hostnames.all { host ->
srv.id == s.id || !srv.server.contains(host)
}
}
private fun hasAllPresets(presetServers: List<ServerCfg>, servers: List<ServerCfg>, m: ChatModel): Boolean =
presetServers.all { hasPreset(it, servers) } ?: true
private fun addAllPresets(rhId: Long?, presetServers: List<ServerCfg>, servers: List<ServerCfg>, m: ChatModel): List<ServerCfg> {
val toAdd = ArrayList<ServerCfg>()
for (srv in presetServers) {
if (!hasPreset(srv, servers)) {
toAdd.add(srv)
}
}
return toAdd
}
private fun hasPreset(srv: ServerCfg, servers: List<ServerCfg>): Boolean =
servers.any { it.server == srv.server }
private suspend fun testServers(testing: MutableState<Boolean>, servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit) {
val resetStatus = resetTestStatus(servers)
onUpdated(resetStatus)
testing.value = true
val fs = runServersTest(resetStatus, m) { onUpdated(it) }
testing.value = false
if (fs.isNotEmpty()) {
val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n")
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_test_failed),
text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg
)
}
}
private fun resetTestStatus(servers: List<ServerCfg>): List<ServerCfg> {
val copy = ArrayList(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
copy.removeAt(index)
copy.add(index, server.copy(tested = null))
}
}
return copy
}
private suspend fun runServersTest(servers: List<ServerCfg>, m: ChatModel, onUpdated: (List<ServerCfg>) -> Unit): Map<String, ProtocolTestFailure> {
val fs: MutableMap<String, ProtocolTestFailure> = mutableMapOf()
val updatedServers = ArrayList<ServerCfg>(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
interruptIfCancelled()
val (updatedServer, f) = testServerConnection(server, m)
updatedServers.removeAt(index)
updatedServers.add(index, updatedServer)
// toList() is important. Otherwise, Compose will not redraw the screen after first update
onUpdated(updatedServers.toList())
if (f != null) {
fs[serverHostname(updatedServer.server)] = f
}
}
}
return fs
}
private fun saveServers(rhId: Long?, protocol: ServerProtocol, currServers: MutableState<List<ServerCfg>>, servers: List<ServerCfg>, m: ChatModel, afterSave: () -> Unit = {}) {
withBGApi {
if (m.controller.setUserProtoServers(rhId, protocol, servers)) {
currServers.value = servers
}
afterSave()
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.smp_save_servers_question),
confirmText = generalGetString(MR.strings.save_verb),
dismissText = generalGetString(MR.strings.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}

View file

@ -25,14 +25,13 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.database.DatabaseView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrateFromDeviceView
import chat.simplex.common.views.onboarding.SimpleXInfo
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView
import chat.simplex.res.MR
import kotlinx.coroutines.*
@Composable
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) {
@ -102,7 +101,7 @@ fun SettingsLayout(
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) })
@ -118,7 +117,7 @@ fun SettingsLayout(
SectionView(stringResource(MR.strings.settings_section_title_help)) {
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close = close) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
if (!chatModel.desktopNoUserNoRemote) {
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)

View file

@ -1,4 +1,4 @@
package chat.simplex.common.views.usersettings
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import SectionDividerSpaced
@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.usersettings.SettingsPreferenceItem
import chat.simplex.res.MR
import java.text.DecimalFormat

View file

@ -1,6 +1,7 @@
package chat.simplex.common.views.usersettings
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionItemWithValue
@ -20,119 +21,245 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.input.*
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.Image
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.getServerOperators
import chat.simplex.common.model.ChatController.getUserServers
import chat.simplex.common.model.ChatController.setUserServers
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingActionButton
import chat.simplex.common.views.onboarding.ReadableText
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import kotlinx.coroutines.launch
@Composable
fun NetworkAndServersView() {
fun ModalData.NetworkAndServersView(close: () -> Unit) {
val currentRemoteHost by remember { chatModel.currentRemoteHost }
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
val netCfg = remember { chatModel.controller.getNetCfg() }
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) }
val currUserServers = remember { stateGetOrPut("currUserServers") { emptyList<UserOperatorServers>() } }
val userServers = remember { stateGetOrPut("userServers") { emptyList<UserOperatorServers>() } }
val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList<UserServersError>() } }
val scope = rememberCoroutineScope()
val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } }
NetworkAndServersLayout(
currentRemoteHost = currentRemoteHost,
networkUseSocksProxy = networkUseSocksProxy,
onionHosts = remember { mutableStateOf(netCfg.onionHosts) },
toggleSocksProxy = { enable ->
val def = NetCfg.defaults
val proxyDef = NetCfg.proxyDefaults
if (enable) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.network_enable_socks),
text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value),
confirmText = generalGetString(MR.strings.confirm_verb),
onConfirm = {
withBGApi {
var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get())
if (conf.tcpConnectTimeout == def.tcpConnectTimeout) {
conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout)
}
if (conf.tcpTimeout == def.tcpTimeout) {
conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout)
}
if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) {
conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb)
}
if (conf.rcvConcurrency == def.rcvConcurrency) {
conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency)
}
chatModel.controller.apiSetNetworkConfig(conf)
chatModel.controller.setNetCfg(conf)
networkUseSocksProxy.value = true
}
}
)
ModalView(
close = {
if (!serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)) {
close()
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.network_disable_socks),
text = generalGetString(MR.strings.network_disable_socks_info),
confirmText = generalGetString(MR.strings.confirm_verb),
onConfirm = {
withBGApi {
var conf = controller.getNetCfg().copy(socksProxy = null)
if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) {
conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout)
}
if (conf.tcpTimeout == proxyDef.tcpTimeout) {
conf = conf.copy(tcpTimeout = def.tcpTimeout)
}
if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) {
conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb)
}
if (conf.rcvConcurrency == proxyDef.rcvConcurrency) {
conf = conf.copy(rcvConcurrency = def.rcvConcurrency)
}
chatModel.controller.apiSetNetworkConfig(conf)
chatModel.controller.setNetCfg(conf)
networkUseSocksProxy.value = false
}
}
showUnsavedChangesAlert(
{ scope.launch { saveServers(currentRemoteHost?.remoteHostId, currUserServers, userServers) }},
close
)
}
}
)
) {
NetworkAndServersLayout(
currentRemoteHost = currentRemoteHost,
networkUseSocksProxy = networkUseSocksProxy,
onionHosts = remember { mutableStateOf(netCfg.onionHosts) },
currUserServers = currUserServers,
userServers = userServers,
serverErrors = serverErrors,
toggleSocksProxy = { enable ->
val def = NetCfg.defaults
val proxyDef = NetCfg.proxyDefaults
if (enable) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.network_enable_socks),
text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value),
confirmText = generalGetString(MR.strings.confirm_verb),
onConfirm = {
withBGApi {
var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get())
if (conf.tcpConnectTimeout == def.tcpConnectTimeout) {
conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout)
}
if (conf.tcpTimeout == def.tcpTimeout) {
conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout)
}
if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) {
conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb)
}
if (conf.rcvConcurrency == def.rcvConcurrency) {
conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency)
}
chatModel.controller.apiSetNetworkConfig(conf)
chatModel.controller.setNetCfg(conf)
networkUseSocksProxy.value = true
}
}
)
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.network_disable_socks),
text = generalGetString(MR.strings.network_disable_socks_info),
confirmText = generalGetString(MR.strings.confirm_verb),
onConfirm = {
withBGApi {
var conf = controller.getNetCfg().copy(socksProxy = null)
if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) {
conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout)
}
if (conf.tcpTimeout == proxyDef.tcpTimeout) {
conf = conf.copy(tcpTimeout = def.tcpTimeout)
}
if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) {
conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb)
}
if (conf.rcvConcurrency == proxyDef.rcvConcurrency) {
conf = conf.copy(rcvConcurrency = def.rcvConcurrency)
}
chatModel.controller.apiSetNetworkConfig(conf)
chatModel.controller.setNetCfg(conf)
networkUseSocksProxy.value = false
}
}
)
}
}
)
}
}
@Composable fun NetworkAndServersLayout(
currentRemoteHost: RemoteHostInfo?,
networkUseSocksProxy: MutableState<Boolean>,
onionHosts: MutableState<OnionHosts>,
currUserServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
userServers: MutableState<List<UserOperatorServers>>,
toggleSocksProxy: (Boolean) -> Unit,
) {
val m = chatModel
val conditionsAction = remember { m.conditions.value.conditionsAction }
val anyOperatorEnabled = remember { derivedStateOf { userServers.value.any { it.operator?.enabled == true } } }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
if (currUserServers.value.isNotEmpty() || userServers.value.isNotEmpty()) {
return@LaunchedEffect
}
try {
val servers = getUserServers(rh = currentRemoteHost?.remoteHostId)
if (servers != null) {
currUserServers.value = servers
userServers.value = servers
}
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
}
}
@Composable
fun ConditionsButton(conditionsAction: UsageConditionsAction, rhId: Long?) {
SectionItemView(
click = { ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> UsageConditionsView(currUserServers, userServers, close, rhId) } },
) {
Text(
stringResource(if (conditionsAction is UsageConditionsAction.Review) MR.strings.operator_review_conditions else MR.strings.operator_conditions_accepted),
color = MaterialTheme.colors.primary
)
}
}
ColumnWithScrollBar {
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) }}
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) } }
AppBarTitle(stringResource(MR.strings.network_and_servers))
// TODO: Review this and socks.
if (!chatModel.desktopNoUserNoRemote) {
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.message_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) } })
SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) {
userServers.value.forEachIndexed { index, srv ->
srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, currentRemoteHost?.remoteHostId) }
}
}
if (conditionsAction != null && anyOperatorEnabled.value) {
ConditionsButton(conditionsAction, rhId = currentRemoteHost?.remoteHostId)
}
val footerText = if (conditionsAction is UsageConditionsAction.Review && conditionsAction.deadline != null && anyOperatorEnabled.value) {
String.format(generalGetString(MR.strings.operator_conditions_will_be_accepted_on), localDate(conditionsAction.deadline))
} else null
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.media_and_file_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } })
if (footerText != null) {
SectionTextFooter(footerText)
}
SectionDividerSpaced()
}
if (currentRemoteHost == null) {
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) }})
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } })
if (networkUseSocksProxy.value) {
SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
SectionDividerSpaced(maxTopPadding = true)
} else {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
val nullOperatorIndex = userServers.value.indexOfFirst { it.operator == null }
if (nullOperatorIndex != -1) {
SectionItemView({
ModalManager.start.showModal {
YourServersView(
userServers = userServers,
serverErrors = serverErrors,
operatorIndex = nullOperatorIndex,
rhId = currentRemoteHost?.remoteHostId
)
}
}) {
Icon(
painterResource(MR.images.ic_dns),
stringResource(MR.strings.your_servers),
tint = MaterialTheme.colors.secondary
)
TextIconSpaced()
Text(stringResource(MR.strings.your_servers), color = MaterialTheme.colors.onBackground)
if (currUserServers.value.getOrNull(nullOperatorIndex) != userServers.value.getOrNull(nullOperatorIndex)) {
Spacer(Modifier.weight(1f))
UnsavedChangesIndicator()
}
}
}
if (currentRemoteHost == null) {
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) } })
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } })
if (networkUseSocksProxy.value) {
SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
SectionDividerSpaced(maxTopPadding = true)
} else {
SectionDividerSpaced(maxBottomPadding = false)
}
}
}
val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)
SectionItemView(
{ scope.launch { saveServers(rhId = currentRemoteHost?.remoteHostId, currUserServers, userServers) } },
disabled = saveDisabled,
) {
Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
val serversErr = globalServersError(serverErrors.value)
if (serversErr != null) {
SectionCustomFooter {
ServersErrorFooter(serversErr)
}
} else if (serverErrors.value.isNotEmpty()) {
SectionCustomFooter {
ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration))
}
}
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.settings_section_title_calls)) {
SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } })
@ -504,6 +631,165 @@ fun showWrongProxyConfigAlert() {
)
}
@Composable()
private fun ServerOperatorRow(
index: Int,
operator: ServerOperator,
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
rhId: Long?
) {
SectionItemView(
{
ModalManager.start.showModalCloseable { close ->
OperatorView(
currUserServers,
userServers,
serverErrors,
index,
rhId
)
}
}
) {
Image(
painterResource(operator.logo),
operator.tradeName,
modifier = Modifier.size(24.dp),
colorFilter = if (operator.enabled) null else ColorFilter.colorMatrix(ColorMatrix().apply {
setToSaturation(0f)
})
)
TextIconSpaced()
Text(operator.tradeName, color = if (operator.enabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
if (currUserServers.value.getOrNull(index) != userServers.value.getOrNull(index)) {
Spacer(Modifier.weight(1f))
UnsavedChangesIndicator()
}
}
}
@Composable
private fun UnsavedChangesIndicator() {
Icon(
painterResource(MR.images.ic_edit_filled),
stringResource(MR.strings.icon_descr_edited),
tint = MaterialTheme.colors.secondary,
modifier = Modifier.size(16.dp)
)
}
@Composable
fun UsageConditionsView(
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
close: () -> Unit,
rhId: Long?
) {
suspend fun acceptForOperators(rhId: Long?, operatorIds: List<Long>, close: () -> Unit) {
try {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
val r = chatController.acceptConditions(rhId, conditionsId, operatorIds) ?: return
chatModel.conditions.value = r
updateOperatorsConditionsAcceptance(currUserServers, r.serverOperators)
updateOperatorsConditionsAcceptance(userServers, r.serverOperators)
close()
} catch (ex: Exception) {
Log.e(TAG, ex.stackTraceToString())
}
}
@Composable
fun AcceptConditionsButton(operatorIds: List<Long>, close: () -> Unit, bottomPadding: Dp = DEFAULT_PADDING * 2) {
val scope = rememberCoroutineScope()
Column(Modifier.fillMaxWidth().padding(bottom = bottomPadding), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingActionButton(
labelId = MR.strings.accept_conditions,
onboarding = null,
enabled = operatorIds.isNotEmpty(),
onclick = {
scope.launch {
acceptForOperators(rhId, operatorIds, close)
}
}
)
}
}
ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false)
when (val conditionsAction = chatModel.conditions.value.conditionsAction) {
is UsageConditionsAction.Review -> {
if (conditionsAction.operators.isNotEmpty()) {
ReadableText(MR.strings.operators_conditions_will_be_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ })
}
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
ConditionsTextView(rhId)
}
AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, if (conditionsAction.deadline != null) DEFAULT_PADDING_HALF else DEFAULT_PADDING * 2)
if (conditionsAction.deadline != null) {
SectionTextFooter(
text = AnnotatedString(String.format(generalGetString(MR.strings.operator_conditions_accepted_for_enabled_operators_on), localDate(conditionsAction.deadline))),
textAlign = TextAlign.Center
)
Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING))
}
}
is UsageConditionsAction.Accepted -> {
if (conditionsAction.operators.isNotEmpty()) {
ReadableText(MR.strings.operators_conditions_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ })
}
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
ConditionsTextView(rhId)
}
}
else -> {
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
ConditionsTextView(rhId)
}
}
}
}
}
@Composable
fun ServersErrorFooter(errStr: String) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painterResource(MR.images.ic_error),
contentDescription = stringResource(MR.strings.server_error),
tint = Color.Red,
modifier = Modifier
.size(19.sp.toDp())
.offset(x = 2.sp.toDp())
)
TextIconSpaced()
Text(
errStr,
color = MaterialTheme.colors.secondary,
lineHeight = 18.sp,
fontSize = 14.sp
)
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.smp_save_servers_question),
confirmText = generalGetString(MR.strings.save_verb),
dismissText = generalGetString(MR.strings.exit_without_saving),
onConfirm = save,
onDismiss = revert,
)
}
fun showUpdateNetworkSettingsDialog(
title: String,
startsWith: String = "",
@ -521,6 +807,107 @@ fun showUpdateNetworkSettingsDialog(
)
}
fun updateOperatorsConditionsAcceptance(usvs: MutableState<List<UserOperatorServers>>, updatedOperators: List<ServerOperator>) {
val modified = ArrayList(usvs.value)
for (i in modified.indices) {
val updatedOperator = updatedOperators.firstOrNull { it.operatorId == modified[i].operator?.operatorId } ?: continue
modified[i] = modified[i].copy(operator = modified[i].operator?.copy(conditionsAcceptance = updatedOperator.conditionsAcceptance))
}
usvs.value = modified
}
suspend fun validateServers_(
rhId: Long?,
userServersToValidate: List<UserOperatorServers>,
serverErrors: MutableState<List<UserServersError>>
) {
try {
val errors = chatController.validateServers(rhId, userServersToValidate) ?: return
serverErrors.value = errors
} catch (ex: Exception) {
Log.e(TAG, ex.stackTraceToString())
}
}
fun serversCanBeSaved(
currUserServers: List<UserOperatorServers>,
userServers: List<UserOperatorServers>,
serverErrors: List<UserServersError>
): Boolean {
return userServers != currUserServers && serverErrors.isEmpty()
}
fun globalServersError(serverErrors: List<UserServersError>): String? {
for (err in serverErrors) {
if (err.globalError != null) {
return err.globalError
}
}
return null
}
fun globalSMPServersError(serverErrors: List<UserServersError>): String? {
for (err in serverErrors) {
if (err.globalSMPError != null) {
return err.globalSMPError
}
}
return null
}
fun globalXFTPServersError(serverErrors: List<UserServersError>): String? {
for (err in serverErrors) {
if (err.globalXFTPError != null) {
return err.globalXFTPError
}
}
return null
}
fun findDuplicateHosts(serverErrors: List<UserServersError>): Set<String> {
val duplicateHostsList = serverErrors.mapNotNull { err ->
if (err is UserServersError.DuplicateServer) {
err.duplicateHost
} else {
null
}
}
return duplicateHostsList.toSet()
}
private suspend fun saveServers(
rhId: Long?,
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>
) {
val userServersToSave = userServers.value
try {
val set = setUserServers(rhId, userServersToSave)
if (set) {
// Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers)
val updatedServers = getUserServers(rhId)
// Get updated operators to update model
val updatedOperators = getServerOperators(rhId)
if (updatedOperators != null) {
chatModel.conditions.value = updatedOperators
}
if (updatedServers != null ) {
currUserServers.value = updatedServers
userServers.value = updatedServers
} else {
currUserServers.value = userServersToSave
}
} else {
currUserServers.value = userServersToSave
}
} catch (ex: Exception) {
Log.e(TAG, ex.stackTraceToString())
}
}
@Preview
@Composable
fun PreviewNetworkAndServersLayout() {
@ -530,6 +917,9 @@ fun PreviewNetworkAndServersLayout() {
networkUseSocksProxy = remember { mutableStateOf(true) },
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
toggleSocksProxy = {},
currUserServers = remember { mutableStateOf(emptyList()) },
userServers = remember { mutableStateOf(emptyList()) },
serverErrors = remember { mutableStateOf(emptyList()) }
)
}
}

View file

@ -0,0 +1,144 @@
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.*
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
@Composable
fun ModalData.NewServerView(
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
rhId: Long?,
close: () -> Unit
) {
val testing = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val newServer = remember { mutableStateOf(UserServer.empty) }
ModalView(close = {
addServer(
scope,
newServer.value,
userServers,
serverErrors,
rhId,
close = close
)
}) {
Box {
NewServerLayout(
newServer,
testing.value,
testServer = {
testing.value = true
withLongRunningApi {
val res = testServerConnection(newServer.value, chatModel)
if (isActive) {
newServer.value = res.first
testing.value = false
}
}
},
)
if (testing.value) {
DefaultProgressView(null)
}
}
}
}
@Composable
private fun NewServerLayout(
server: MutableState<UserServer>,
testing: Boolean,
testServer: () -> Unit,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.smp_servers_new_server))
CustomServer(server, testing, testServer, onDelete = null)
SectionBottomSpacer()
}
}
fun serverProtocolAndOperator(
server: UserServer,
userServers: List<UserOperatorServers>
): Pair<ServerProtocol, ServerOperator?>? {
val serverAddress = parseServerAddress(server.server)
return if (serverAddress != null) {
val serverProtocol = serverAddress.serverProtocol
val hostnames = serverAddress.hostnames
val matchingOperator = userServers.mapNotNull { it.operator }.firstOrNull { op ->
op.serverDomains.any { domain ->
hostnames.any { hostname ->
hostname.endsWith(domain)
}
}
}
Pair(serverProtocol, matchingOperator)
} else {
null
}
}
fun addServer(
scope: CoroutineScope,
server: UserServer,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
rhId: Long?,
close: () -> Unit
) {
val result = serverProtocolAndOperator(server, userServers.value)
if (result != null) {
val (serverProtocol, matchingOperator) = result
val operatorIndex = userServers.value.indexOfFirst { it.operator?.operatorId == matchingOperator?.operatorId }
if (operatorIndex != -1) {
// Create a mutable copy of the userServers list
val updatedUserServers = userServers.value.toMutableList()
val operatorServers = updatedUserServers[operatorIndex]
// Create a mutable copy of the smpServers or xftpServers and add the server
when (serverProtocol) {
ServerProtocol.SMP -> {
val updatedSMPServers = operatorServers.smpServers.toMutableList()
updatedSMPServers.add(server)
updatedUserServers[operatorIndex] = operatorServers.copy(smpServers = updatedSMPServers)
}
ServerProtocol.XFTP -> {
val updatedXFTPServers = operatorServers.xftpServers.toMutableList()
updatedXFTPServers.add(server)
updatedUserServers[operatorIndex] = operatorServers.copy(xftpServers = updatedXFTPServers)
}
}
userServers.value = updatedUserServers
close()
matchingOperator?.let { op ->
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.operator_server_alert_title),
text = String.format(generalGetString(MR.strings.server_added_to_operator__name), op.tradeName)
)
}
} else { // Shouldn't happen
close()
AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.error_adding_server))
}
} else {
close()
if (server.server.trim().isNotEmpty()) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_invalid_address),
text = generalGetString(MR.strings.smp_servers_check_address)
)
}
}
}

View file

@ -0,0 +1,708 @@
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.getUsageConditions
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.net.URI
@Composable
fun ModalData.OperatorView(
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
rhId: Long?
) {
val testing = remember { mutableStateOf(false) }
val operator = remember { userServers.value[operatorIndex].operator_ }
val currentUser = remember { chatModel.currentUser }.value
LaunchedEffect(userServers) {
snapshotFlow { userServers.value }
.collect { updatedServers ->
validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors)
}
}
Box {
ColumnWithScrollBar(Modifier.alpha(if (testing.value) 0.6f else 1f)) {
AppBarTitle(String.format(stringResource(MR.strings.operator_servers_title), operator.tradeName))
OperatorViewLayout(
currUserServers,
userServers,
serverErrors,
operatorIndex,
navigateToProtocolView = { serverIndex, server, protocol ->
navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol)
},
currentUser,
rhId,
testing
)
}
if (testing.value) {
DefaultProgressView(null)
}
}
}
fun navigateToProtocolView(
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
rhId: Long?,
serverIndex: Int,
server: UserServer,
protocol: ServerProtocol
) {
ModalManager.start.showCustomModal { close ->
ProtocolServerView(
m = chatModel,
server = server,
serverProtocol = protocol,
userServers = userServers,
serverErrors = serverErrors,
onDelete = {
if (protocol == ServerProtocol.SMP) {
deleteSMPServer(userServers, operatorIndex, serverIndex)
} else {
deleteXFTPServer(userServers, operatorIndex, serverIndex)
}
close()
},
onUpdate = { updatedServer ->
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
smpServers = if (protocol == ServerProtocol.SMP) {
this[operatorIndex].smpServers.toMutableList().apply {
this[serverIndex] = updatedServer
}
} else this[operatorIndex].smpServers,
xftpServers = if (protocol == ServerProtocol.XFTP) {
this[operatorIndex].xftpServers.toMutableList().apply {
this[serverIndex] = updatedServer
}
} else this[operatorIndex].xftpServers
)
}
},
close = close,
rhId = rhId
)
}
}
@Composable
fun OperatorViewLayout(
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit,
currentUser: User?,
rhId: Long?,
testing: MutableState<Boolean>
) {
val operator by remember { derivedStateOf { userServers.value[operatorIndex].operator_ } }
val scope = rememberCoroutineScope()
val duplicateHosts = findDuplicateHosts(serverErrors.value)
Column {
SectionView(generalGetString(MR.strings.operator).uppercase()) {
SectionItemView({ ModalManager.start.showModalCloseable { _ -> OperatorInfoView(operator) } }) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Image(painterResource(operator.largeLogo), null, Modifier.height(48.dp))
Spacer(Modifier.fillMaxWidth().weight(1f))
Box(Modifier.padding(horizontal = 2.dp)) {
Icon(painterResource(MR.images.ic_info), null, Modifier.size(24.dp), tint = MaterialTheme.colors.primaryVariant)
}
}
}
UseOperatorToggle(
currUserServers = currUserServers,
userServers = userServers,
serverErrors = serverErrors,
operatorIndex = operatorIndex,
rhId = rhId
)
}
val serversErr = globalServersError(serverErrors.value)
if (serversErr != null) {
SectionCustomFooter {
ServersErrorFooter(serversErr)
}
} else {
val footerText = when (val c = operator.conditionsAcceptance) {
is ConditionsAcceptance.Accepted -> if (c.acceptedAt != null) {
String.format(generalGetString(MR.strings.operator_conditions_accepted_on), localDate(c.acceptedAt))
} else null
is ConditionsAcceptance.Required -> if (operator.enabled && c.deadline != null) {
String.format(generalGetString(MR.strings.operator_conditions_will_be_accepted_on), localDate(c.deadline))
} else null
}
if (footerText != null) {
SectionTextFooter(footerText)
}
}
if (operator.enabled) {
if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.operator_use_for_messages).uppercase()) {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
stringResource(MR.strings.operator_use_for_messages_receiving),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = userServers.value[operatorIndex].operator_.smpRoles.storage,
onCheckedChange = { enabled ->
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
)
)
}
}
)
}
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
stringResource(MR.strings.operator_use_for_messages_private_routing),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = userServers.value[operatorIndex].operator_.smpRoles.proxy,
onCheckedChange = { enabled ->
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled)
)
)
}
}
)
}
}
val smpErr = globalSMPServersError(serverErrors.value)
if (smpErr != null) {
SectionCustomFooter {
ServersErrorFooter(smpErr)
}
}
}
// Preset servers can't be deleted
if (userServers.value[operatorIndex].smpServers.any { it.preset }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.message_servers).uppercase()) {
userServers.value[operatorIndex].smpServers.forEachIndexed { i, server ->
if (!server.preset) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.SMP,
duplicateHosts = duplicateHosts
)
}
}
}
val smpErr = globalSMPServersError(serverErrors.value)
if (smpErr != null) {
SectionCustomFooter {
ServersErrorFooter(smpErr)
}
} else {
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.smp_servers_per_user) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
}
}
if (userServers.value[operatorIndex].smpServers.any { !it.preset && !it.deleted }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.operator_added_message_servers).uppercase()) {
userServers.value[operatorIndex].smpServers.forEachIndexed { i, server ->
if (server.deleted || server.preset) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.SMP,
duplicateHosts = duplicateHosts
)
}
}
}
}
if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.operator_use_for_files).uppercase()) {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
stringResource(MR.strings.operator_use_for_sending),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = userServers.value[operatorIndex].operator_.xftpRoles.storage,
onCheckedChange = { enabled ->
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
)
)
}
}
)
}
}
val xftpErr = globalXFTPServersError(serverErrors.value)
if (xftpErr != null) {
SectionCustomFooter {
ServersErrorFooter(xftpErr)
}
}
}
// Preset servers can't be deleted
if (userServers.value[operatorIndex].xftpServers.any { it.preset }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) {
userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server ->
if (!server.preset) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.XFTP,
duplicateHosts = duplicateHosts
)
}
}
}
val xftpErr = globalXFTPServersError(serverErrors.value)
if (xftpErr != null) {
SectionCustomFooter {
ServersErrorFooter(xftpErr)
}
} else {
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.xftp_servers_per_user) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
}
}
if (userServers.value[operatorIndex].xftpServers.any { !it.preset && !it.deleted}) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.operator_added_xftp_servers).uppercase()) {
userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server ->
if (server.deleted || server.preset) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.XFTP,
duplicateHosts = duplicateHosts
)
}
}
}
}
SectionDividerSpaced()
SectionView {
TestServersButton(
testing = testing,
smpServers = userServers.value[operatorIndex].smpServers,
xftpServers = userServers.value[operatorIndex].xftpServers,
) { p, l ->
when (p) {
ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
xftpServers = l
)
}
ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
smpServers = l
)
}
}
}
}
SectionBottomSpacer()
}
}
}
@Composable
private fun OperatorInfoView(serverOperator: ServerOperator) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.operator_info_title))
SectionView {
SectionItemView {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Image(painterResource(serverOperator.largeLogo), null, Modifier.height(48.dp))
if (serverOperator.legalName != null) {
Text(serverOperator.legalName)
}
}
}
}
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
SectionItemView {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
serverOperator.info.description.forEach { d ->
Text(d)
}
}
}
}
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.operator_website).uppercase()) {
SectionItemView {
val website = serverOperator.info.website
val uriHandler = LocalUriHandler.current
Text(website, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(website) })
}
}
}
}
@Composable
private fun UseOperatorToggle(
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
rhId: Long?
) {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
stringResource(MR.strings.operator_use_operator_toggle_description),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = userServers.value[operatorIndex].operator?.enabled ?: false,
onCheckedChange = { enabled ->
val operator = userServers.value[operatorIndex].operator
if (enabled) {
when (val conditionsAcceptance = operator?.conditionsAcceptance) {
is ConditionsAcceptance.Accepted -> {
changeOperatorEnabled(userServers, operatorIndex, true)
}
is ConditionsAcceptance.Required -> {
if (conditionsAcceptance.deadline == null) {
ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
SingleOperatorUsageConditionsView(
currUserServers = currUserServers,
userServers = userServers,
serverErrors = serverErrors,
operatorIndex = operatorIndex,
rhId = rhId,
close = close
)
}
} else {
changeOperatorEnabled(userServers, operatorIndex, true)
}
}
else -> {}
}
} else {
changeOperatorEnabled(userServers, operatorIndex, false)
}
},
)
}
}
@Composable
private fun SingleOperatorUsageConditionsView(
currUserServers: MutableState<List<UserOperatorServers>>,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
rhId: Long?,
close: () -> Unit
) {
val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } }
val operator = remember { userServers.value[operatorIndex].operator_ }
val scope = rememberCoroutineScope()
suspend fun acceptForOperators(rhId: Long?, operatorIds: List<Long>, operatorIndexToEnable: Int, close: () -> Unit) {
try {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
val r = chatController.acceptConditions(rhId, conditionsId, operatorIds) ?: return
chatModel.conditions.value = r
updateOperatorsConditionsAcceptance(currUserServers, r.serverOperators)
updateOperatorsConditionsAcceptance(userServers, r.serverOperators)
changeOperatorEnabled(userServers, operatorIndex, true)
close()
} catch (ex: Exception) {
Log.e(TAG, ex.stackTraceToString())
}
}
@Composable
fun AcceptConditionsButton(close: () -> Unit) {
// Opened operator or Other enabled operators with conditions not accepted
val operatorIds = chatModel.conditions.value.serverOperators
.filter { it.operatorId == operator.id || (it.enabled && !it.conditionsAcceptance.conditionsAccepted) }
.map { it.operatorId }
Column(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING * 2), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingActionButton(
labelId = MR.strings.accept_conditions,
onboarding = null,
enabled = operatorIds.isNotEmpty(),
onclick = {
scope.launch {
acceptForOperators(rhId, operatorIds, operatorIndex, close)
}
}
)
}
}
@Composable
fun UsageConditionsDestinationView(close: () -> Unit) {
ColumnWithScrollBar(modifier = Modifier.fillMaxSize()) {
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false)
Column(modifier = Modifier.weight(1f).padding(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
ConditionsTextView(rhId)
}
}
}
@Composable
fun UsageConditionsNavLinkButton() {
Text(
stringResource(MR.strings.view_conditions),
color = MaterialTheme.colors.primary,
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF).clickable {
ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
UsageConditionsDestinationView(close)
}
}
)
}
ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(String.format(stringResource(MR.strings.use_servers_of_operator_x), operator.tradeName), enableAlphaChanges = false, withPadding = false)
if (operator.conditionsAcceptance is ConditionsAcceptance.Accepted) {
// In current UI implementation this branch doesn't get shown - as conditions can't be opened from inside operator once accepted
Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF, bottom = DEFAULT_PADDING)) {
ConditionsTextView(rhId)
}
} else if (operatorsWithConditionsAccepted.isNotEmpty()) {
ReadableText(
MR.strings.operator_conditions_accepted_for_some,
args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ }
)
ReadableText(
MR.strings.operator_same_conditions_will_be_applied,
args = operator.legalName_
)
ConditionsAppliedToOtherOperatorsText(userServers = userServers.value, operatorIndex = operatorIndex)
UsageConditionsNavLinkButton()
Spacer(Modifier.fillMaxWidth().weight(1f))
AcceptConditionsButton(close)
} else {
ReadableText(
MR.strings.operator_in_order_to_use_accept_conditions,
args = operator.legalName_
)
ConditionsAppliedToOtherOperatorsText(userServers = userServers.value, operatorIndex = operatorIndex)
Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF, bottom = DEFAULT_PADDING)) {
ConditionsTextView(rhId)
}
AcceptConditionsButton(close)
}
}
}
@Composable
fun ConditionsTextView(
rhId: Long?
) {
val conditionsData = remember { mutableStateOf<Triple<UsageConditionsDetail, String?, UsageConditionsDetail?>?>(null) }
val failedToLoad = remember { mutableStateOf(false) }
val defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md"
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch {
try {
val conditions = getUsageConditions(rh = rhId)
if (conditions != null) {
conditionsData.value = conditions
} else {
failedToLoad.value = true
}
} catch (ex: Exception) {
failedToLoad.value = true
}
}
}
val conditions = conditionsData.value
if (conditions != null) {
val (usageConditions, conditionsText, _) = conditions
if (conditionsText != null) {
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.border(border = BorderStroke(1.dp, CurrentColors.value.colors.secondary.copy(alpha = 0.6f)), shape = RoundedCornerShape(12.dp))
.verticalScroll(scrollState)
.padding(8.dp)
) {
Text(
text = conditionsText.trimIndent(),
modifier = Modifier.padding(8.dp)
)
}
} else {
val conditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/${usageConditions.conditionsCommit}/PRIVACY.md"
ConditionsLinkView(conditionsLink)
}
} else if (failedToLoad.value) {
ConditionsLinkView(defaultConditionsLink)
} else {
DefaultProgressView(null)
}
}
@Composable
private fun ConditionsLinkView(conditionsLink: String) {
SectionItemView {
val uriHandler = LocalUriHandler.current
Text(stringResource(MR.strings.operator_conditions_failed_to_load), color = MaterialTheme.colors.onBackground)
Text(conditionsLink, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(conditionsLink) })
}
}
@Composable
private fun ConditionsAppliedToOtherOperatorsText(userServers: List<UserOperatorServers>, operatorIndex: Int) {
val otherOperatorsToApply = remember {
derivedStateOf {
chatModel.conditions.value.serverOperators.filter {
it.enabled &&
!it.conditionsAcceptance.conditionsAccepted &&
it.operatorId != userServers[operatorIndex].operator_.operatorId
}
}
}
if (otherOperatorsToApply.value.isNotEmpty()) {
ReadableText(MR.strings.operator_conditions_will_be_applied)
}
}
@Composable
fun ConditionsLinkButton() {
val showMenu = remember { mutableStateOf(false) }
val uriHandler = LocalUriHandler.current
val oneHandUI = remember { appPrefs.oneHandUI.state }
Column {
DefaultDropdownMenu(showMenu, offset = if (oneHandUI.value) DpOffset(0.dp, -AppBarHeight * fontSizeSqrtMultiplier * 3) else DpOffset.Zero) {
val commit = chatModel.conditions.value.currentConditions.conditionsCommit
ItemAction(stringResource(MR.strings.operator_open_conditions), painterResource(MR.images.ic_draft), onClick = {
val mdUrl = "https://github.com/simplex-chat/simplex-chat/blob/$commit/PRIVACY.md"
uriHandler.openUriCatching(mdUrl)
showMenu.value = false
})
ItemAction(stringResource(MR.strings.operator_open_changes), painterResource(MR.images.ic_more_horiz), onClick = {
val commitUrl = "https://github.com/simplex-chat/simplex-chat/commit/$commit"
uriHandler.openUriCatching(commitUrl)
showMenu.value = false
})
}
IconButton({ showMenu.value = true }) {
Icon(painterResource(MR.images.ic_outbound), null, tint = MaterialTheme.colors.primary)
}
}
}
private fun changeOperatorEnabled(userServers: MutableState<List<UserOperatorServers>>, operatorIndex: Int, enabled: Boolean) {
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(enabled = enabled)
)
}
}

View file

@ -1,16 +1,14 @@
package chat.simplex.common.views.usersettings
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionItemViewSpaceBetween
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
@ -26,62 +24,103 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCode
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.views.usersettings.PreferenceToggle
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun ProtocolServerView(m: ChatModel, server: ServerCfg, serverProtocol: ServerProtocol, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) {
var testing by remember { mutableStateOf(false) }
ProtocolServerLayout(
testing,
server,
serverProtocol,
testServer = {
testing = true
withLongRunningApi {
val res = testServerConnection(server, m)
if (isActive) {
onUpdate(res.first)
testing = false
fun ProtocolServerView(
m: ChatModel,
server: UserServer,
serverProtocol: ServerProtocol,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
onDelete: () -> Unit,
onUpdate: (UserServer) -> Unit,
close: () -> Unit,
rhId: Long?
) {
val testing = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val draftServer = remember { mutableStateOf(server) }
ModalView(
close = {
scope.launch {
val draftResult = serverProtocolAndOperator(draftServer.value, userServers.value)
val savedResult = serverProtocolAndOperator(server, userServers.value)
if (draftResult != null && savedResult != null) {
val (serverToEditProtocol, serverToEditOperator) = draftResult
val (svProtocol, serverOperator) = savedResult
if (serverToEditProtocol != svProtocol) {
close()
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_updating_server_title),
text = generalGetString(MR.strings.error_server_protocol_changed)
)
} else if (serverToEditOperator != serverOperator) {
close()
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_updating_server_title),
text = generalGetString(MR.strings.error_server_operator_changed)
)
} else {
onUpdate(draftServer.value)
close()
}
} else {
close()
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_invalid_address),
text = generalGetString(MR.strings.smp_servers_check_address)
)
}
}
},
onUpdate,
onDelete
)
if (testing) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.5.dp
}
) {
Box {
ProtocolServerLayout(
draftServer,
serverProtocol,
testing.value,
testServer = {
testing.value = true
withLongRunningApi {
val res = testServerConnection(draftServer.value, m)
if (isActive) {
draftServer.value = res.first
testing.value = false
}
}
},
onDelete
)
if (testing.value) {
DefaultProgressView(null)
}
}
}
}
@Composable
private fun ProtocolServerLayout(
testing: Boolean,
server: ServerCfg,
server: MutableState<UserServer>,
serverProtocol: ServerProtocol,
testing: Boolean,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(if (server.preset) MR.strings.smp_servers_preset_server else MR.strings.smp_servers_your_server))
AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.XFTP) MR.strings.xftp_server else MR.strings.smp_server))
if (server.preset) {
PresetServer(testing, server, testServer, onUpdate, onDelete)
if (server.value.preset) {
PresetServer(server, testing, testServer)
} else {
CustomServer(testing, server, serverProtocol, testServer, onUpdate, onDelete)
CustomServer(server, testing, testServer, onDelete)
}
SectionBottomSpacer()
}
@ -89,16 +128,14 @@ private fun ProtocolServerLayout(
@Composable
private fun PresetServer(
server: MutableState<UserServer>,
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
testServer: () -> Unit
) {
SectionView(stringResource(MR.strings.smp_servers_preset_address).uppercase()) {
SelectionContainer {
Text(
server.server,
server.value.server,
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
style = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
@ -108,23 +145,21 @@ private fun PresetServer(
}
}
SectionDividerSpaced()
UseServerSection(true, testing, server, testServer, onUpdate, onDelete)
UseServerSection(server, true, testing, testServer)
}
@Composable
private fun CustomServer(
fun CustomServer(
server: MutableState<UserServer>,
testing: Boolean,
server: ServerCfg,
serverProtocol: ServerProtocol,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
onDelete: (() -> Unit)?,
) {
val serverAddress = remember { mutableStateOf(server.server) }
val serverAddress = remember { mutableStateOf(server.value.server) }
val valid = remember {
derivedStateOf {
with(parseServerAddress(serverAddress.value)) {
this?.valid == true && this.serverProtocol == serverProtocol
this?.valid == true
}
}
}
@ -142,13 +177,14 @@ private fun CustomServer(
snapshotFlow { serverAddress.value }
.distinctUntilChanged()
.collect {
testedPreviously[server.server] = server.tested
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value]))
testedPreviously[server.value.server] = server.value.tested
server.value = server.value.copy(server = it, tested = testedPreviously[serverAddress.value])
}
}
}
SectionDividerSpaced(maxTopPadding = true)
UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete)
UseServerSection(server, valid.value, testing, testServer, onDelete)
if (valid.value) {
SectionDividerSpaced()
@ -160,43 +196,44 @@ private fun CustomServer(
@Composable
private fun UseServerSection(
server: MutableState<UserServer>,
valid: Boolean,
testing: Boolean,
server: ServerCfg,
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
onDelete: (() -> Unit)? = null,
) {
SectionView(stringResource(MR.strings.smp_servers_use_server).uppercase()) {
SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) {
Text(stringResource(MR.strings.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
ShowTestStatus(server)
ShowTestStatus(server.value)
}
val enabled = rememberUpdatedState(server.enabled)
val enabled = rememberUpdatedState(server.value.enabled)
PreferenceToggle(
stringResource(MR.strings.smp_servers_use_server_for_new_conn),
disabled = server.tested != true && !server.preset,
disabled = testing,
checked = enabled.value
) {
onUpdate(server.copy(enabled = it))
server.value = server.value.copy(enabled = it)
}
SectionItemView(onDelete, disabled = testing) {
Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error)
if (onDelete != null) {
SectionItemView(onDelete, disabled = testing) {
Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error)
}
}
}
}
@Composable
fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) =
fun ShowTestStatus(server: UserServer, modifier: Modifier = Modifier) =
when (server.tested) {
true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen)
false -> Icon(painterResource(MR.images.ic_close), null, modifier, tint = MaterialTheme.colors.error)
else -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = Color.Transparent)
}
suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCfg, ProtocolTestFailure?> =
suspend fun testServerConnection(server: UserServer, m: ChatModel): Pair<UserServer, ProtocolTestFailure?> =
try {
val r = m.controller.testProtoServer(server.remoteHostId, server.server)
server.copy(tested = r == null) to r

View file

@ -0,0 +1,407 @@
package chat.simplex.common.views.usersettings.networkAndServers
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
fun ModalData.YourServersView(
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
rhId: Long?
) {
val testing = remember { mutableStateOf(false) }
val currentUser = remember { chatModel.currentUser }.value
val scope = rememberCoroutineScope()
LaunchedEffect(userServers) {
snapshotFlow { userServers.value }
.collect { updatedServers ->
validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors)
}
}
Box {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.your_servers))
YourServersViewLayout(
scope,
userServers,
serverErrors,
operatorIndex,
navigateToProtocolView = { serverIndex, server, protocol ->
navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol)
},
currentUser,
rhId,
testing
)
}
if (testing.value) {
DefaultProgressView(null)
}
}
}
@Composable
fun YourServersViewLayout(
scope: CoroutineScope,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
operatorIndex: Int,
navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit,
currentUser: User?,
rhId: Long?,
testing: MutableState<Boolean>
) {
val duplicateHosts = findDuplicateHosts(serverErrors.value)
Column {
if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) {
SectionView(generalGetString(MR.strings.message_servers).uppercase()) {
userServers.value[operatorIndex].smpServers.forEachIndexed { i, server ->
if (server.deleted) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.SMP,
duplicateHosts = duplicateHosts
)
}
}
}
val smpErr = globalSMPServersError(serverErrors.value)
if (smpErr != null) {
SectionCustomFooter {
ServersErrorFooter(smpErr)
}
} else {
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.smp_servers_per_user) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
}
}
if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) {
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) {
userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server ->
if (server.deleted) return@forEachIndexed
SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) {
ProtocolServerViewLink(
srv = server,
serverProtocol = ServerProtocol.XFTP,
duplicateHosts = duplicateHosts
)
}
}
}
val xftpErr = globalXFTPServersError(serverErrors.value)
if (xftpErr != null) {
SectionCustomFooter {
ServersErrorFooter(xftpErr)
}
} else {
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.xftp_servers_per_user) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
}
}
if (
userServers.value[operatorIndex].smpServers.any { !it.deleted } ||
userServers.value[operatorIndex].xftpServers.any { !it.deleted }
) {
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
}
SectionView {
SettingsActionItem(
painterResource(MR.images.ic_add),
stringResource(MR.strings.smp_servers_add),
click = { showAddServerDialog(scope, userServers, serverErrors, rhId) },
disabled = testing.value,
textColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
val serversErr = globalServersError(serverErrors.value)
if (serversErr != null) {
SectionCustomFooter {
ServersErrorFooter(serversErr)
}
}
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
SectionView {
TestServersButton(
testing = testing,
smpServers = userServers.value[operatorIndex].smpServers,
xftpServers = userServers.value[operatorIndex].xftpServers,
) { p, l ->
when (p) {
ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
xftpServers = l
)
}
ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
smpServers = l
)
}
}
}
HowToButton()
}
SectionBottomSpacer()
}
}
@Composable
fun TestServersButton(
smpServers: List<UserServer>,
xftpServers: List<UserServer>,
testing: MutableState<Boolean>,
onUpdate: (ServerProtocol, List<UserServer>) -> Unit
) {
val scope = rememberCoroutineScope()
val disabled = derivedStateOf { (smpServers.none { it.enabled } && xftpServers.none { it.enabled }) || testing.value }
SectionItemView(
{
scope.launch {
testServers(testing, smpServers, xftpServers, chatModel, onUpdate)
}
},
disabled = disabled.value
) {
Text(stringResource(MR.strings.smp_servers_test_servers), color = if (!disabled.value) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
}
fun showAddServerDialog(
scope: CoroutineScope,
userServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
rhId: Long?
) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.smp_servers_add),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
ModalManager.start.showCustomModal { close ->
NewServerView(userServers, serverErrors, rhId, close)
}
}) {
Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
if (appPlatform.isAndroid) {
SectionItemView({
AlertManager.shared.hideAlert()
ModalManager.start.showModalCloseable { close ->
ScanProtocolServer(rhId) { server ->
addServer(
scope,
server,
userServers,
serverErrors,
rhId,
close = close
)
}
}
}
) {
Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
}
)
}
@Composable
fun ProtocolServerViewLink(serverProtocol: ServerProtocol, srv: UserServer, duplicateHosts: Set<String>) {
val address = parseServerAddress(srv.server)
when {
address == null || !address.valid || address.serverProtocol != serverProtocol || address.hostnames.any { it in duplicateHosts } -> InvalidServer()
!srv.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary)
else -> ShowTestStatus(srv)
}
Spacer(Modifier.padding(horizontal = 4.dp))
val text = address?.hostnames?.firstOrNull() ?: srv.server
if (srv.enabled) {
Text(text, color = MaterialTheme.colors.onBackground, maxLines = 1)
} else {
Text(text, maxLines = 1, color = MaterialTheme.colors.secondary)
}
}
@Composable
private fun HowToButton() {
val uriHandler = LocalUriHandler.current
SettingsActionItem(
painterResource(MR.images.ic_open_in_new),
stringResource(MR.strings.how_to_use_your_servers),
{ uriHandler.openUriCatching("https://simplex.chat/docs/server.html") },
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary
)
}
@Composable
fun InvalidServer() {
Icon(painterResource(MR.images.ic_error), null, tint = MaterialTheme.colors.error)
}
private suspend fun testServers(
testing: MutableState<Boolean>,
smpServers: List<UserServer>,
xftpServers: List<UserServer>,
m: ChatModel,
onUpdate: (ServerProtocol, List<UserServer>) -> Unit
) {
val smpResetStatus = resetTestStatus(smpServers)
onUpdate(ServerProtocol.SMP, smpResetStatus)
val xftpResetStatus = resetTestStatus(xftpServers)
onUpdate(ServerProtocol.XFTP, xftpResetStatus)
testing.value = true
val smpFailures = runServersTest(smpResetStatus, m) { onUpdate(ServerProtocol.SMP, it) }
val xftpFailures = runServersTest(xftpResetStatus, m) { onUpdate(ServerProtocol.XFTP, it) }
testing.value = false
val fs = smpFailures + xftpFailures
if (fs.isNotEmpty()) {
val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n")
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_test_failed),
text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg
)
}
}
private fun resetTestStatus(servers: List<UserServer>): List<UserServer> {
val copy = ArrayList(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
copy.removeAt(index)
copy.add(index, server.copy(tested = null))
}
}
return copy
}
private suspend fun runServersTest(servers: List<UserServer>, m: ChatModel, onUpdated: (List<UserServer>) -> Unit): Map<String, ProtocolTestFailure> {
val fs: MutableMap<String, ProtocolTestFailure> = mutableMapOf()
val updatedServers = ArrayList<UserServer>(servers)
for ((index, server) in servers.withIndex()) {
if (server.enabled) {
interruptIfCancelled()
val (updatedServer, f) = testServerConnection(server, m)
updatedServers.removeAt(index)
updatedServers.add(index, updatedServer)
// toList() is important. Otherwise, Compose will not redraw the screen after first update
onUpdated(updatedServers.toList())
if (f != null) {
fs[serverHostname(updatedServer.server)] = f
}
}
}
return fs
}
fun deleteXFTPServer(
userServers: MutableState<List<UserOperatorServers>>,
operatorServersIndex: Int,
serverIndex: Int
) {
val serverIsSaved = userServers.value[operatorServersIndex].xftpServers[serverIndex].serverId != null
if (serverIsSaved) {
userServers.value = userServers.value.toMutableList().apply {
this[operatorServersIndex] = this[operatorServersIndex].copy(
xftpServers = this[operatorServersIndex].xftpServers.toMutableList().apply {
this[serverIndex] = this[serverIndex].copy(deleted = true)
}
)
}
} else {
userServers.value = userServers.value.toMutableList().apply {
this[operatorServersIndex] = this[operatorServersIndex].copy(
xftpServers = this[operatorServersIndex].xftpServers.toMutableList().apply {
this.removeAt(serverIndex)
}
)
}
}
}
fun deleteSMPServer(
userServers: MutableState<List<UserOperatorServers>>,
operatorServersIndex: Int,
serverIndex: Int
) {
val serverIsSaved = userServers.value[operatorServersIndex].smpServers[serverIndex].serverId != null
if (serverIsSaved) {
userServers.value = userServers.value.toMutableList().apply {
this[operatorServersIndex] = this[operatorServersIndex].copy(
smpServers = this[operatorServersIndex].smpServers.toMutableList().apply {
this[serverIndex] = this[serverIndex].copy(deleted = true)
}
)
}
} else {
userServers.value = userServers.value.toMutableList().apply {
this[operatorServersIndex] = this[operatorServersIndex].copy(
smpServers = this[operatorServersIndex].smpServers.toMutableList().apply {
this.removeAt(serverIndex)
}
)
}
}
}

View file

@ -1,29 +1,25 @@
package chat.simplex.common.views.usersettings
package chat.simplex.common.views.usersettings.networkAndServers
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.model.ServerCfg
import chat.simplex.common.model.UserServer
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCodeScanner
import chat.simplex.res.MR
@Composable
expect fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit)
expect fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit)
@Composable
fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) {
fun ScanProtocolServerLayout(rhId: Long?, onNext: (UserServer) -> Unit) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr))
QRCodeScanner { text ->
val res = parseServerAddress(text)
if (res != null) {
onNext(ServerCfg(remoteHostId = rhId, text, false, null, false))
onNext(UserServer(remoteHostId = rhId, null, text, false, null, false, false))
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_invalid_address),

View file

@ -109,6 +109,16 @@
<string name="failed_to_create_user_invalid_title">Invalid display name!</string>
<string name="failed_to_create_user_invalid_desc">This display name is invalid. Please choose another name.</string>
<string name="failed_to_active_user_title">Error switching profile!</string>
<string name="failed_to_save_servers">Error saving servers</string>
<string name="no_message_servers_configured">No message servers.</string>
<string name="no_message_servers_configured_for_receiving">No servers to receive messages.</string>
<string name="no_message_servers_configured_for_private_routing">No servers for private message routing.</string>
<string name="no_media_servers_configured">No media &amp; file servers.</string>
<string name="no_media_servers_configured_for_sending">No servers to send files.</string>
<string name="no_media_servers_configured_for_private_routing">No servers to receive files.</string>
<string name="for_chat_profile">For chat profile %s:</string>
<string name="errors_in_servers_configuration">Errors in servers configuration.</string>
<string name="error_accepting_operator_conditions">Error accepting conditions</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Connection timeout</string>
@ -750,6 +760,7 @@
<string name="smp_servers_test_some_failed">Some servers failed the test:</string>
<string name="smp_servers_scan_qr">Scan server QR code</string>
<string name="smp_servers_enter_manually">Enter server manually</string>
<string name="smp_servers_new_server">New server</string>
<string name="smp_servers_preset_server">Preset server</string>
<string name="smp_servers_your_server">Your server</string>
<string name="smp_servers_your_server_address">Your server address</string>
@ -1038,6 +1049,19 @@
<string name="you_can_change_it_later">Random passphrase is stored in settings as plaintext.\nYou can change it later.</string>
<string name="use_random_passphrase">Use random passphrase</string>
<!-- ChooseServerOperators.kt -->
<string name="onboarding_choose_server_operators">Choose operators</string>
<string name="onboarding_network_operators">Network operators</string>
<string name="onboarding_network_operators_app_will_use_different_operators">When more than one network operator is enabled, the app will use the servers of different operators for each conversation.</string>
<string name="onboarding_network_operators_app_will_use_for_routing">For example, if you receive messages via SimpleX Chat server, the app will use one of Flux servers for private routing.</string>
<string name="onboarding_select_network_operators_to_use">Select network operators to use.</string>
<string name="onboarding_network_operators_configure_via_settings">You can configure servers via settings.</string>
<string name="onboarding_network_operators_conditions_will_be_accepted">Conditions will be accepted for enabled operators after 30 days.</string>
<string name="onboarding_network_operators_conditions_you_can_configure">You can configure operators in Network &amp; servers settings.</string>
<string name="onboarding_network_operators_review_later">Review later</string>
<string name="onboarding_network_operators_update">Update</string>
<string name="onboarding_network_operators_continue">Continue</string>
<!-- Call -->
<string name="incoming_video_call">Incoming video call</string>
<string name="incoming_audio_call">Incoming audio call</string>
@ -1667,6 +1691,59 @@
<string name="save_group_profile">Save group profile</string>
<string name="error_saving_group_profile">Error saving group profile</string>
<!-- NetworkAndServers.kt -->
<string name="network_preset_servers_title">Preset servers</string>
<string name="operator_review_conditions">Review conditions</string>
<string name="operator_conditions_accepted">Accepted conditions</string>
<string name="operator_conditions_accepted_for_enabled_operators_on">Conditions will be automatically accepted for enabled operators on: %s.</string>
<string name="your_servers">Your servers</string>
<string name="operators_conditions_accepted_for"><![CDATA[Conditions are accepted for the operator(s): <b>%s</b>.]]></string>
<string name="operators_conditions_will_be_accepted_for"><![CDATA[Conditions will be accepted for the operator(s): <b>%s</b>.]]></string>
<!-- OperatorView.kt -->
<string name="operator">Operator</string>
<string name="operator_servers_title">%s servers</string>
<string name="operator_info_title">Network operator</string>
<string name="operator_website">Website</string>
<string name="operator_conditions_accepted_on">Conditions accepted on: %s.</string>
<string name="operator_conditions_will_be_accepted_on">Conditions will be accepted on: %s.</string>
<string name="operator">Operator</string>
<string name="operator_use_operator_toggle_description">Use servers</string>
<string name="use_servers_of_operator_x">Use %s</string>
<string name="operator_conditions_failed_to_load">Current conditions text couldn\'t be loaded, you can review conditions via this link:</string>
<string name="operator_conditions_accepted_for_some"><![CDATA[Conditions are already accepted for following operator(s): <b>%s</b>.]]></string>
<string name="operator_same_conditions_will_be_applied"><![CDATA[Same conditions will apply to operator <b>%s</b>.]]></string>
<string name="operator_same_conditions_will_apply_to_operators"><![CDATA[Same conditions will apply to operator(s): <b>%s</b>.]]></string>
<string name="operator_conditions_accepted_for_some"><![CDATA[Conditions are already accepted for following operator(s): <b>%s</b>.]]></string>
<string name="operator_conditions_will_be_applied"><![CDATA[These conditions will also apply for: <b>%s</b>.]]></string>
<string name="operator_same_conditions_will_be_applied"><![CDATA[Same conditions will apply to operator: <b>%s</b>.]]></string>
<string name="operator_conditions_will_be_accepted_for_some"><![CDATA[Conditions will be accepted for operator(s): <b>%s</b>.]]></string>
<string name="operators_conditions_will_also_apply"><![CDATA[These conditions will also apply for: <b>%s</b>.]]></string>
<string name="view_conditions">View conditions</string>
<string name="accept_conditions">Accept conditions</string>
<string name="operator_conditions_of_use">Conditions of use</string>
<string name="operator_in_order_to_use_accept_conditions"><![CDATA[To use the servers of <b>%s</b>, accept conditions of use.]]></string>
<string name="operator_use_for_messages">Use for messages</string>
<string name="operator_use_for_messages_receiving">To receive</string>
<string name="operator_use_for_messages_private_routing">For private routing</string>
<string name="operator_added_message_servers">Added message servers</string>
<string name="operator_use_for_files">Use for files</string>
<string name="operator_use_for_sending">To send</string>
<string name="xftp_servers_per_user">The servers for new files of your current chat profile</string>
<string name="operator_added_xftp_servers">Added media &amp; file servers</string>
<string name="operator_open_conditions">Open conditions</string>
<string name="operator_open_changes">Open changes</string>
<!-- ProtocolServerView.kt -->
<string name="error_updating_server_title">Error updating server</string>
<string name="error_server_protocol_changed">Server protocol changed.</string>
<string name="error_server_operator_changed">Server operator changed.</string>
<!-- NewServerView.kt -->
<string name="operator_server_alert_title">Operator server</string>
<string name="server_added_to_operator__name">Server added to operator %s.</string>
<string name="error_adding_server">Error adding server</string>
<!-- AdvancedNetworkSettings.kt -->
<string name="network_option_tcp_connection">TCP connection</string>
<string name="network_options_reset_to_defaults">Reset to defaults</string>
@ -2059,6 +2136,13 @@
<string name="v6_1_message_dates_descr">Better message dates.</string>
<string name="v6_1_forward_many_messages_descr">Forward up to 20 messages at once.</string>
<string name="v6_1_delete_many_messages_descr">Delete or moderate up to 200 messages.</string>
<string name="v6_2_network_decentralization">Network decentralization</string>
<string name="v6_2_network_decentralization_descr">The second preset operator in the app!</string>
<string name="v6_2_network_decentralization_enable_flux">Enable flux</string>
<string name="v6_2_network_decentralization_enable_flux_reason">for better metadata privacy.</string>
<string name="v6_2_improved_chat_navigation">Improved chat navigation</string>
<string name="v6_2_improved_chat_navigation_descr">- Open chat on the first unread message.\n- Jump to quoted messages.</string>
<string name="view_updated_conditions">View updated conditions</string>
<!-- CustomTimePicker -->
<string name="custom_time_unit_seconds">seconds</string>

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m346-306 235.5-235v125.5H639V-639H415.5v57.5H540L306-347l40 41ZM480.06-85q-80.97 0-153.13-31.26-72.15-31.27-125.79-85Q147.5-255 116.25-327.02 85-399.05 85-479.94q0-81.97 31.26-154.13 31.27-72.15 85-125.54Q255-813 327.02-844q72.03-31 152.92-31 81.97 0 154.13 31.13 72.17 31.13 125.55 84.5Q813-706 844-633.98q31 72.03 31 153.92 0 80.97-31.01 153.13-31.02 72.15-84.5 125.79Q706-147.5 633.98-116.25 561.95-85 480.06-85Zm-.09-57.5q140.53 0 239.03-98.97 98.5-98.96 98.5-238.5 0-140.53-98.47-239.03-98.46-98.5-239-98.5-139.53 0-238.53 98.47-99 98.46-99 239 0 139.53 98.97 238.53 98.96 99 238.5 99ZM480-480Z"/></svg>

After

Width:  |  Height:  |  Size: 724 B

View file

@ -1,9 +0,0 @@
package chat.simplex.common.views.usersettings
import androidx.compose.runtime.Composable
import chat.simplex.common.model.ServerCfg
@Composable
actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) {
ScanProtocolServerLayout(rhId, onNext)
}

View file

@ -0,0 +1,9 @@
package chat.simplex.common.views.usersettings.networkAndServers
import androidx.compose.runtime.Composable
import chat.simplex.common.model.UserServer
@Composable
actual fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) {
ScanProtocolServerLayout(rhId, onNext)
}

View file

@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: a64c1aa2c41938c5e18cc49d08075f14e5d25f0d
tag: 97104988a307bd27b8bf5da7ed67455f3531d7ae
source-repository-package
type: git

View file

@ -0,0 +1,24 @@
# Server operators
## Problem
All preconfigured servers operated by a single company create a risk that user connections can be analysed by aggregating transport information from these servers.
The solution is to have more than one operator servers pre-configured in the app.
For operators to be protected from any violations of rights of other users or third parties by the users who use servers of these operators, the users have to explicitely accept conditions of use with the operator, in the same way they accept conditions of use with SimpleX Chat Ltd by downloading the app.
## Solution
Allow to assign operators to servers, both with preconfigured operators and servers, and with user-defined operators. Agent added support for server roles, chat app could:
- allow assigning server roles only on the operator level.
- only on server level.
- on both, with server roles overriding operator roles (that would require a different type for server for chat app).
For simplicity of both UX and logic it is probably better to allow assigning roles only on operators' level, and servers without set operators can be used for both roles.
For agreements, it is sufficient to record the signatures of these agreements on users' devices, together with the copy of signed agreement (or its hash and version) in a separate table. The terms themselves could be:
- included in the app - either in code or in migration.
- referenced with a stable link to a particular commit.
The first solution seems better, as it avoids any third party dependency, and the agreement size is relatively small (~31kb), to reduce size we can store it compressed.

View file

@ -29,6 +29,7 @@ dependencies:
- email-validate == 2.3.*
- exceptions == 0.10.*
- filepath == 1.4.*
- file-embed == 0.0.15.*
- http-types == 0.12.*
- http2 >= 4.2.2 && < 4.3
- memory == 0.18.*
@ -38,6 +39,7 @@ dependencies:
- optparse-applicative >= 0.15 && < 0.17
- random >= 1.1 && < 1.3
- record-hasfield == 1.0.*
- scientific ==0.3.7.*
- simple-logger == 0.1.*
- simplexmq >= 5.0
- socks == 0.6.*
@ -72,7 +74,7 @@ when:
- bytestring == 0.10.*
- process >= 1.6 && < 1.6.18
- template-haskell == 2.16.*
- text >= 1.2.3.0 && < 1.3
- text >= 1.2.4.0 && < 1.3
library:
source-dirs: src

View file

@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."a64c1aa2c41938c5e18cc49d08075f14e5d25f0d" = "1kf86vrh5zfrqyczfjcj3d2nagmqb0rwhhdc10fw5n8jcgmdw6rp";
"https://github.com/simplex-chat/simplexmq.git"."97104988a307bd27b8bf5da7ed67455f3531d7ae" = "1xhk8cg4338d0cfjhdm2460p6nbvxfra80qnab2607nvy8wpddvl";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";

View file

@ -151,10 +151,13 @@ library
Simplex.Chat.Migrations.M20241008_indexes
Simplex.Chat.Migrations.M20241010_contact_requests_contact_id
Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id
Simplex.Chat.Migrations.M20241027_server_operators
Simplex.Chat.Mobile
Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared
Simplex.Chat.Mobile.WebRTC
Simplex.Chat.Operators
Simplex.Chat.Operators.Conditions
Simplex.Chat.Options
Simplex.Chat.ProfileGenerator
Simplex.Chat.Protocol
@ -214,6 +217,7 @@ library
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
@ -224,6 +228,7 @@ library
, optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3
, record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.*
, simplexmq >=5.0
, socks ==0.6.*
@ -250,7 +255,7 @@ library
bytestring ==0.10.*
, process >=1.6 && <1.6.18
, template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3
, text >=1.2.4.0 && <1.3
executable simplex-bot
main-is: Main.hs
@ -277,6 +282,7 @@ executable simplex-bot
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
@ -287,6 +293,7 @@ executable simplex-bot
, optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3
, record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.*
, simplex-chat
, simplexmq >=5.0
@ -314,7 +321,7 @@ executable simplex-bot
bytestring ==0.10.*
, process >=1.6 && <1.6.18
, template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3
, text >=1.2.4.0 && <1.3
executable simplex-bot-advanced
main-is: Main.hs
@ -341,6 +348,7 @@ executable simplex-bot-advanced
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
@ -351,6 +359,7 @@ executable simplex-bot-advanced
, optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3
, record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.*
, simplex-chat
, simplexmq >=5.0
@ -378,7 +387,7 @@ executable simplex-bot-advanced
bytestring ==0.10.*
, process >=1.6 && <1.6.18
, template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3
, text >=1.2.4.0 && <1.3
executable simplex-broadcast-bot
main-is: Main.hs
@ -408,6 +417,7 @@ executable simplex-broadcast-bot
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
@ -418,6 +428,7 @@ executable simplex-broadcast-bot
, optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3
, record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.*
, simplex-chat
, simplexmq >=5.0
@ -445,7 +456,7 @@ executable simplex-broadcast-bot
bytestring ==0.10.*
, process >=1.6 && <1.6.18
, template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3
, text >=1.2.4.0 && <1.3
executable simplex-chat
main-is: Main.hs
@ -473,6 +484,7 @@ executable simplex-chat
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
@ -483,6 +495,7 @@ executable simplex-chat
, optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3
, record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.*
, simplex-chat
, simplexmq >=5.0
@ -511,7 +524,7 @@ executable simplex-chat
bytestring ==0.10.*
, process >=1.6 && <1.6.18
, template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3
, text >=1.2.4.0 && <1.3
executable simplex-directory-service
main-is: Main.hs
@ -544,6 +557,7 @@ executable simplex-directory-service
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
@ -554,6 +568,7 @@ executable simplex-directory-service
, optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3
, record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.*
, simplex-chat
, simplexmq >=5.0
@ -581,7 +596,7 @@ executable simplex-directory-service
bytestring ==0.10.*
, process >=1.6 && <1.6.18
, template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3
, text >=1.2.4.0 && <1.3
test-suite simplex-chat-test
type: exitcode-stdio-1.0
@ -603,6 +618,7 @@ test-suite simplex-chat-test
MarkdownTests
MessageBatching
MobileTests
OperatorTests
ProtocolTests
RandomServers
RemoteTests
@ -643,6 +659,7 @@ test-suite simplex-chat-test
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.*
, generic-random ==1.5.*
, http-types ==0.12.*
@ -654,6 +671,7 @@ test-suite simplex-chat-test
, optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3
, record-hasfield ==1.0.*
, scientific ==0.3.7.*
, silently ==1.2.*
, simple-logger ==0.1.*
, simplex-chat
@ -682,7 +700,7 @@ test-suite simplex-chat-test
bytestring ==0.10.*
, process >=1.6 && <1.6.18
, template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3
, text >=1.2.4.0 && <1.3
if impl(ghc >= 9.6.2)
build-depends:
hspec ==2.11.*

View file

@ -3,9 +3,11 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE RankNTypes #-}
@ -43,7 +45,7 @@ import Data.Functor (($>))
import Data.Functor.Identity
import Data.Int (Int64)
import Data.List (find, foldl', isSuffixOf, mapAccumL, partition, sortOn, zipWith4)
import Data.List.NonEmpty (NonEmpty (..), nonEmpty, toList, (<|))
import Data.List.NonEmpty (NonEmpty (..), (<|))
import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
@ -54,6 +56,7 @@ import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds)
import Data.Type.Equality
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as V4
import Data.Word (Word32)
@ -67,6 +70,7 @@ import Simplex.Chat.Messages
import Simplex.Chat.Messages.Batch (MsgBatch (..), batchMessages)
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Messages.CIContent.Events
import Simplex.Chat.Operators
import Simplex.Chat.Options
import Simplex.Chat.ProfileGenerator (generateRandomProfile)
import Simplex.Chat.Protocol
@ -97,7 +101,7 @@ import qualified Simplex.FileTransfer.Transport as XFTP
import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId)
import Simplex.Messaging.Agent as Agent
import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, getFastNetworkConfig, ipAddressProtected, withLockMap)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), createAgentStore, defaultAgentConfig, enabledServerCfg, presetServerCfg)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), ServerRoles (..), allRoles, createAgentStore, defaultAgentConfig)
import Simplex.Messaging.Agent.Lock (withLock)
import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..))
@ -137,6 +141,34 @@ import qualified UnliftIO.Exception as E
import UnliftIO.IO (hClose, hSeek, hTell, openFile)
import UnliftIO.STM
operatorSimpleXChat :: NewServerOperator
operatorSimpleXChat =
ServerOperator
{ operatorId = DBNewEntity,
operatorTag = Just OTSimplex,
tradeName = "SimpleX Chat",
legalName = Just "SimpleX Chat Ltd",
serverDomains = ["simplex.im"],
conditionsAcceptance = CARequired Nothing,
enabled = True,
smpRoles = allRoles,
xftpRoles = allRoles
}
operatorFlux :: NewServerOperator
operatorFlux =
ServerOperator
{ operatorId = DBNewEntity,
operatorTag = Just OTFlux,
tradeName = "Flux",
legalName = Just "InFlux Technologies Limited",
serverDomains = ["simplexonflux.com"],
conditionsAcceptance = CARequired Nothing,
enabled = False,
smpRoles = ServerRoles {storage = False, proxy = True},
xftpRoles = allRoles
}
defaultChatConfig :: ChatConfig
defaultChatConfig =
ChatConfig
@ -147,13 +179,27 @@ defaultChatConfig =
},
chatVRange = supportedChatVRange,
confirmMigrations = MCConsole,
defaultServers =
DefaultAgentServers
{ smp = _defaultSMPServers,
useSMP = 4,
-- this property should NOT use operator = Nothing
-- non-operator servers can be passed via options
presetServers =
PresetServers
{ operators =
[ PresetOperator
{ operator = Just operatorSimpleXChat,
smp = simplexChatSMPServers,
useSMP = 4,
xftp = map (presetServer True) $ L.toList defaultXFTPServers,
useXFTP = 3
},
PresetOperator
{ operator = Just operatorFlux,
smp = fluxSMPServers,
useSMP = 3,
xftp = fluxXFTPServers,
useXFTP = 3
}
],
ntf = _defaultNtfServers,
xftp = L.map (presetServerCfg True) defaultXFTPServers,
useXFTP = L.length defaultXFTPServers,
netCfg = defaultNetworkConfig
},
tbqSize = 1024,
@ -177,29 +223,52 @@ defaultChatConfig =
chatHooks = defaultChatHooks
}
_defaultSMPServers :: NonEmpty (ServerCfg 'PSMP)
_defaultSMPServers =
L.fromList $
map
(presetServerCfg True)
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
"smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion",
"smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion",
"smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion",
"smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion",
"smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion",
"smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion",
"smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion",
"smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion",
"smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion",
"smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion"
simplexChatSMPServers :: [NewUserServer 'PSMP]
simplexChatSMPServers =
map
(presetServer True)
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
"smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion",
"smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion",
"smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion",
"smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion",
"smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion",
"smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion",
"smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion",
"smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion",
"smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion",
"smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion"
]
<> map
(presetServer False)
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion",
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion",
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion"
]
<> map
(presetServerCfg False)
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion",
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion",
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion"
]
fluxSMPServers :: [NewUserServer 'PSMP]
fluxSMPServers =
map
(presetServer True)
[ "smp://xQW_ufMkGE20UrTlBl8QqceG1tbuylXhr9VOLPyRJmw=@smp1.simplexonflux.com,qb4yoanyl4p7o33yrknv4rs6qo7ugeb2tu2zo66sbebezs4cpyosarid.onion",
"smp://LDnWZVlAUInmjmdpQQoIo6FUinRXGe0q3zi5okXDE4s=@smp2.simplexonflux.com,yiqtuh3q4x7hgovkomafsod52wvfjucdljqbbipg5sdssnklgongxbqd.onion",
"smp://1jne379u7IDJSxAvXbWb_JgoE7iabcslX0LBF22Rej0=@smp3.simplexonflux.com,a5lm4k7ufei66cdck6fy63r4lmkqy3dekmmb7jkfdm5ivi6kfaojshad.onion",
"smp://xmAmqj75I9mWrUihLUlI0ZuNLXlIwFIlHRq5Pb6cHAU=@smp4.simplexonflux.com,qpcz2axyy66u26hfdd2e23uohcf3y6c36mn7dcuilcgnwjasnrvnxjqd.onion",
"smp://rWvBYyTamuRCBYb_KAn-nsejg879ndhiTg5Sq3k0xWA=@smp5.simplexonflux.com,4ao347qwiuluyd45xunmii4skjigzuuox53hpdsgbwxqafd4yrticead.onion",
"smp://PN7-uqLBToqlf1NxHEaiL35lV2vBpXq8Nj8BW11bU48=@smp6.simplexonflux.com,hury6ot3ymebbr2535mlp7gcxzrjpc6oujhtfxcfh2m4fal4xw5fq6qd.onion"
]
fluxXFTPServers :: [NewUserServer 'PXFTP]
fluxXFTPServers =
map
(presetServer True)
[ "xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com,apl3pumq3emwqtrztykyyoomdx4dg6ysql5zek2bi3rgznz7ai3odkid.onion",
"xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com,c5jjecisncnngysah3cz2mppediutfelco4asx65mi75d44njvua3xid.onion",
"xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com,dc4mohiubvbnsdfqqn7xhlhpqs5u4tjzp7xpz6v6corwvzvqjtaqqiqd.onion",
"xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com,4qq5pzier3i4yhpuhcrhfbl6j25udc4czoyascrj4yswhodhfwev3nyd.onion",
"xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com,q7itltdn32hjmgcqwhow4tay5ijetng3ur32bolssw32fvc5jrwvozad.onion",
"xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com,upvzf23ou6nrmaf3qgnhd6cn3d74tvivlmz3p7wdfwq6fhthjrjiiqid.onion"
]
_defaultNtfServers :: [NtfServer]
_defaultNtfServers =
@ -236,16 +305,23 @@ newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Boo
newChatController
ChatDatabase {chatStore, agentStore}
user
cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, deviceNameForRemote, confirmMigrations}
cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations}
ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable, yesToUpMigrations}, deviceName, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize}
backgroundMode = do
let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False}
confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations
config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'}
config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'}
firstTime = dbNew chatStore
currentUser <- newTVarIO user
randomPresetServers <- chooseRandomServers presetServers'
let rndSrvs = L.toList randomPresetServers
operatorWithId (i, op) = (\o -> o {operatorId = DBEntityId i}) <$> pOperator op
opDomains = operatorDomains $ mapMaybe operatorWithId $ zip [1..] rndSrvs
agentSMP <- randomServerCfgs "agent SMP servers" SPSMP opDomains rndSrvs
agentXFTP <- randomServerCfgs "agent XFTP servers" SPXFTP opDomains rndSrvs
let randomAgentServers = RandomAgentServers {smpServers = agentSMP, xftpServers = agentXFTP}
currentRemoteHost <- newTVarIO Nothing
servers <- agentServers config
servers <- withTransaction chatStore $ \db -> agentServers db config randomPresetServers randomAgentServers
smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode
agentAsync <- newTVarIO Nothing
random <- liftIO C.newRandom
@ -281,6 +357,8 @@ newChatController
ChatController
{ firstTime,
currentUser,
randomPresetServers,
randomAgentServers,
currentRemoteHost,
smpAgent,
agentAsync,
@ -318,28 +396,48 @@ newChatController
contactMergeEnabled
}
where
configServers :: DefaultAgentServers
configServers =
let DefaultAgentServers {smp = defSmp, xftp = defXftp, netCfg} = defaultServers
smp' = maybe defSmp (L.map enabledServerCfg) (nonEmpty smpServers)
xftp' = maybe defXftp (L.map enabledServerCfg) (nonEmpty xftpServers)
in defaultServers {smp = smp', xftp = xftp', netCfg = updateNetworkConfig netCfg simpleNetCfg}
agentServers :: ChatConfig -> IO InitialAgentServers
agentServers config@ChatConfig {defaultServers = defServers@DefaultAgentServers {ntf, netCfg}} = do
users <- withTransaction chatStore getUsers
smp' <- getUserServers users SPSMP
xftp' <- getUserServers users SPXFTP
pure InitialAgentServers {smp = smp', xftp = xftp', ntf, netCfg}
presetServers' :: PresetServers
presetServers' = presetServers {operators = operators', netCfg = netCfg'}
where
getUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => [User] -> SProtocolType p -> IO (Map UserId (NonEmpty (ServerCfg p)))
getUserServers users protocol = case users of
[] -> pure $ M.fromList [(1, cfgServers protocol defServers)]
_ -> M.fromList <$> initialServers
where
initialServers :: IO [(UserId, NonEmpty (ServerCfg p))]
initialServers = mapM (\u -> (aUserId u,) <$> userServers u) users
userServers :: User -> IO (NonEmpty (ServerCfg p))
userServers user' = useServers config protocol <$> withTransaction chatStore (`getProtocolServers` user')
PresetServers {operators, netCfg} = presetServers
netCfg' = updateNetworkConfig netCfg simpleNetCfg
operators' = case (smpServers, xftpServers) of
([], []) -> operators
(smpSrvs, []) -> L.map disableSMP operators <> [custom smpSrvs []]
([], xftpSrvs) -> L.map disableXFTP operators <> [custom [] xftpSrvs]
(smpSrvs, xftpSrvs) -> [custom smpSrvs xftpSrvs]
disableSMP op@PresetOperator {smp} = (op :: PresetOperator) {smp = map disableSrv smp}
disableXFTP op@PresetOperator {xftp} = (op :: PresetOperator) {xftp = map disableSrv xftp}
disableSrv :: forall p. NewUserServer p -> NewUserServer p
disableSrv srv = (srv :: NewUserServer p) {enabled = False}
custom smpSrvs xftpSrvs =
PresetOperator
{ operator = Nothing,
smp = map newUserServer smpSrvs,
useSMP = 0,
xftp = map newUserServer xftpSrvs,
useXFTP = 0
}
randomServerCfgs :: UserProtocol p => String -> SProtocolType p -> [(Text, ServerOperator)] -> [PresetOperator] -> IO (NonEmpty (ServerCfg p))
randomServerCfgs name p opDomains rndSrvs =
toJustOrError name $ L.nonEmpty $ agentServerCfgs p opDomains $ concatMap (pServers p) rndSrvs
agentServers :: DB.Connection -> ChatConfig -> NonEmpty PresetOperator -> RandomAgentServers -> IO InitialAgentServers
agentServers db ChatConfig {presetServers = PresetServers {ntf, netCfg}} presetOps as = do
users <- getUsers db
ops <- getUpdateServerOperators db presetOps (null users)
let opDomains = operatorDomains $ mapMaybe snd ops
(smp', xftp') <- unzip <$> mapM (getServers ops opDomains) users
pure InitialAgentServers {smp = M.fromList smp', xftp = M.fromList xftp', ntf, netCfg}
where
getServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [(Text, ServerOperator)] -> User -> IO ((UserId, NonEmpty (ServerCfg 'PSMP)), (UserId, NonEmpty (ServerCfg 'PXFTP)))
getServers ops opDomains user' = do
smpSrvs <- getProtocolServers db SPSMP user'
xftpSrvs <- getProtocolServers db SPXFTP user'
uss <- groupByOperator' (ops, smpSrvs, xftpSrvs)
ts <- getCurrentTime
uss' <- mapM (setUserServers' db user' ts . updatedUserServers) uss
let auId = aUserId user'
pure $ bimap (auId,) (auId,) $ useServers as opDomains uss'
updateNetworkConfig :: NetworkConfig -> SimpleNetCfg -> NetworkConfig
updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} =
@ -382,33 +480,47 @@ withFileLock :: String -> Int64 -> CM a -> CM a
withFileLock name = withEntityLock name . CLFile
{-# INLINE withFileLock #-}
useServers :: UserProtocol p => ChatConfig -> SProtocolType p -> [ServerCfg p] -> NonEmpty (ServerCfg p)
useServers ChatConfig {defaultServers} p = fromMaybe (cfgServers p defaultServers) . nonEmpty
useServers :: Foldable f => RandomAgentServers -> [(Text, ServerOperator)] -> f UserOperatorServers -> (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))
useServers as opDomains uss =
let smp' = useServerCfgs SPSMP as opDomains $ concatMap (servers' SPSMP) uss
xftp' = useServerCfgs SPXFTP as opDomains $ concatMap (servers' SPXFTP) uss
in (smp', xftp')
randomServers :: forall p. UserProtocol p => SProtocolType p -> ChatConfig -> IO (NonEmpty (ServerCfg p), [ServerCfg p])
randomServers p ChatConfig {defaultServers} = do
let srvs = cfgServers p defaultServers
(enbldSrvs, dsbldSrvs) = L.partition (\ServerCfg {enabled} -> enabled) srvs
toUse = cfgServersToUse p defaultServers
if length enbldSrvs <= toUse
then pure (srvs, [])
else do
(enbldSrvs', srvsToDisable) <- splitAt toUse <$> shuffle enbldSrvs
let dsbldSrvs' = map (\srv -> (srv :: ServerCfg p) {enabled = False}) srvsToDisable
srvs' = sortOn server' $ enbldSrvs' <> dsbldSrvs' <> dsbldSrvs
pure (fromMaybe srvs $ L.nonEmpty srvs', srvs')
useServerCfgs :: forall p. UserProtocol p => SProtocolType p -> RandomAgentServers -> [(Text, ServerOperator)] -> [UserServer p] -> NonEmpty (ServerCfg p)
useServerCfgs p RandomAgentServers {smpServers, xftpServers} opDomains =
fromMaybe (rndAgentServers p) . L.nonEmpty . agentServerCfgs p opDomains
where
server' ServerCfg {server = ProtoServerWithAuth srv _} = srv
rndAgentServers :: SProtocolType p -> NonEmpty (ServerCfg p)
rndAgentServers = \case
SPSMP -> smpServers
SPXFTP -> xftpServers
cfgServers :: UserProtocol p => SProtocolType p -> DefaultAgentServers -> NonEmpty (ServerCfg p)
cfgServers p DefaultAgentServers {smp, xftp} = case p of
SPSMP -> smp
SPXFTP -> xftp
chooseRandomServers :: PresetServers -> IO (NonEmpty PresetOperator)
chooseRandomServers PresetServers {operators} =
forM operators $ \op -> do
smp' <- opSrvs SPSMP op
xftp' <- opSrvs SPXFTP op
pure (op :: PresetOperator) {smp = smp', xftp = xftp'}
where
opSrvs :: forall p. UserProtocol p => SProtocolType p -> PresetOperator -> IO [NewUserServer p]
opSrvs p op = do
let srvs = pServers p op
toUse = operatorServersToUse p op
(enbldSrvs, dsbldSrvs) = partition (\UserServer {enabled} -> enabled) srvs
if toUse <= 0 || toUse >= length enbldSrvs
then pure srvs
else do
(enbldSrvs', srvsToDisable) <- splitAt toUse <$> shuffle enbldSrvs
let dsbldSrvs' = map (\srv -> (srv :: NewUserServer p) {enabled = False}) srvsToDisable
pure $ sortOn server' $ enbldSrvs' <> dsbldSrvs' <> dsbldSrvs
server' UserServer {server = ProtoServerWithAuth srv _} = srv
cfgServersToUse :: UserProtocol p => SProtocolType p -> DefaultAgentServers -> Int
cfgServersToUse p DefaultAgentServers {useSMP, useXFTP} = case p of
SPSMP -> useSMP
SPXFTP -> useXFTP
toJustOrError :: String -> Maybe a -> IO a
toJustOrError name = \case
Just a -> pure a
Nothing -> do
putStrLn $ name <> ": expected Just, exiting"
E.throwIO $ userError name
-- enableSndFiles has no effect when mainApp is True
startChatController :: Bool -> Bool -> CM' (Async ())
@ -438,7 +550,7 @@ startChatController mainApp enableSndFiles = do
startXFTP startWorkers = do
tmp <- readTVarIO =<< asks tempDirectory
runExceptT (withAgent $ \a -> startWorkers a tmp) >>= \case
Left e -> liftIO $ print $ "Error starting XFTP workers: " <> show e
Left e -> liftIO $ putStrLn $ "Error starting XFTP workers: " <> show e
Right _ -> pure ()
startCleanupManager = do
cleanupAsync <- asks cleanupManagerAsync
@ -552,39 +664,43 @@ processChatCommand' vr = \case
forM_ profile $ \Profile {displayName} -> checkValidName displayName
p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile
u <- asks currentUser
(smp, smpServers) <- chooseServers SPSMP
(xftp, xftpServers) <- chooseServers SPXFTP
users <- withFastStore' getUsers
forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash} ->
when (n == displayName) . throwChatError $
if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""}
auId <- withAgent (\a -> createUser a smp xftp)
(uss, (smp', xftp')) <- chooseServers =<< readTVarIO u
auId <- withAgent $ \a -> createUser a smp' xftp'
ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure
user <- withFastStore $ \db -> createUserRecordAt db (AgentUserId auId) p True ts
createPresetContactCards user `catchChatError` \_ -> pure ()
withFastStore $ \db -> createNoteFolder db user
storeServers user smpServers
storeServers user xftpServers
user <- withFastStore $ \db -> do
user <- createUserRecordAt db (AgentUserId auId) p True ts
mapM_ (setUserServers db user ts) uss
createPresetContactCards db user `catchStoreError` \_ -> pure ()
createNoteFolder db user
pure user
atomically . writeTVar u $ Just user
pure $ CRActiveUser user
where
createPresetContactCards :: User -> CM ()
createPresetContactCards user =
withFastStore $ \db -> do
createContact db user simplexStatusContactProfile
createContact db user simplexTeamContactProfile
chooseServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> CM (NonEmpty (ServerCfg p), [ServerCfg p])
chooseServers protocol =
asks currentUser >>= readTVarIO >>= \case
Nothing -> asks config >>= liftIO . randomServers protocol
Just user -> chosenServers =<< withFastStore' (`getProtocolServers` user)
where
chosenServers servers = do
cfg <- asks config
pure (useServers cfg protocol servers, servers)
storeServers user servers =
unless (null servers) . withFastStore $
\db -> overwriteProtocolServers db user servers
createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO ()
createPresetContactCards db user = do
createContact db user simplexStatusContactProfile
createContact db user simplexTeamContactProfile
chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP)))
chooseServers user_ = do
as <- asks randomAgentServers
mapM (withFastStore . flip getUserServers >=> liftIO . groupByOperator) user_ >>= \case
Just uss -> do
let opDomains = operatorDomains $ mapMaybe operator' uss
uss' = map copyServers uss
pure $ (uss',) $ useServers as opDomains uss
Nothing -> do
ps <- asks randomPresetServers
uss <- presetUserServers <$> withFastStore' (\db -> getUpdateServerOperators db ps True)
let RandomAgentServers {smpServers = smp', xftpServers = xftp'} = as
pure (uss, (smp', xftp'))
copyServers :: UserOperatorServers -> UpdatedUserOperatorServers
copyServers UserOperatorServers {operator, smpServers, xftpServers} =
let new srv = AUS SDBNew srv {serverId = DBNewEntity}
in UpdatedUserOperatorServers {operator, smpServers = map new smpServers, xftpServers = map new xftpServers}
coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day)
day = 86400
ListUsers -> CRUsersList <$> withFastStore' getUsersInfo
@ -1482,25 +1598,96 @@ processChatCommand' vr = \case
msgs <- lift $ withAgent' $ \a -> getConnectionMessages a acIds
let ntfMsgs = L.map (\msg -> receivedMsgInfo <$> msg) msgs
pure $ CRConnNtfMessages ntfMsgs
APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do
cfg@ChatConfig {defaultServers} <- asks config
servers <- withFastStore' (`getProtocolServers` user)
pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p (useServers cfg p servers) (cfgServers p defaultServers)
GetUserProtoServers aProtocol -> withUser $ \User {userId} ->
processChatCommand $ APIGetUserProtoServers userId aProtocol
APISetUserProtoServers userId (APSC p (ProtoServersConfig servers))
| null servers || any (\ServerCfg {enabled} -> enabled) servers -> withUserId userId $ \user -> withServerProtocol p $ do
withFastStore $ \db -> overwriteProtocolServers db user servers
cfg <- asks config
lift $ withAgent' $ \a -> setProtocolServers a (aUserId user) $ useServers cfg p servers
ok user
| otherwise -> withUserId userId $ \user -> pure $ chatCmdError (Just user) "all servers are disabled"
SetUserProtoServers serversConfig -> withUser $ \User {userId} ->
processChatCommand $ APISetUserProtoServers userId serversConfig
GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do
srvs <- withFastStore (`getUserServers` user)
liftIO $ CRUserServers user <$> groupByOperator (protocolServers p srvs)
SetUserProtoServers (AProtocolType (p :: SProtocolType p)) srvs -> withUser $ \user@User {userId} -> withServerProtocol p $ do
userServers_ <- liftIO . groupByOperator =<< withFastStore (`getUserServers` user)
case L.nonEmpty userServers_ of
Nothing -> throwChatError $ CECommandError "no servers"
Just userServers -> case srvs of
[] -> throwChatError $ CECommandError "no servers"
_ -> do
srvs' <- mapM aUserServer srvs
processChatCommand $ APISetUserServers userId $ L.map (updatedServers p srvs') userServers
where
aUserServer :: AProtoServerWithAuth -> CM (AUserServer p)
aUserServer (AProtoServerWithAuth p' srv) = case testEquality p p' of
Just Refl -> pure $ AUS SDBNew $ newUserServer srv
Nothing -> throwChatError $ CECommandError $ "incorrect server protocol: " <> B.unpack (strEncode srv)
APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user ->
lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a (aUserId user) server)
TestProtoServer srv -> withUser $ \User {userId} ->
processChatCommand $ APITestProtoServer userId srv
APIGetServerOperators -> CRServerOperatorConditions <$> withFastStore getServerOperators
APISetServerOperators operators -> do
as <- asks randomAgentServers
(opsConds, srvs) <- withFastStore $ \db -> do
liftIO $ setServerOperators db operators
opsConds <- getServerOperators db
let ops = serverOperators opsConds
ops' = map Just ops <> [Nothing]
opDomains = operatorDomains ops
liftIO $ fmap (opsConds,) . mapM (getServers db as ops' opDomains) =<< getUsers db
lift $ withAgent' $ \a -> forM_ srvs $ \(auId, (smp', xftp')) -> do
setProtocolServers a auId smp'
setProtocolServers a auId xftp'
pure $ CRServerOperatorConditions opsConds
where
getServers :: DB.Connection -> RandomAgentServers -> [Maybe ServerOperator] -> [(Text, ServerOperator)] -> User -> IO (UserId, (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP)))
getServers db as ops opDomains user = do
smpSrvs <- getProtocolServers db SPSMP user
xftpSrvs <- getProtocolServers db SPXFTP user
uss <- groupByOperator (ops, smpSrvs, xftpSrvs)
pure $ (aUserId user,) $ useServers as opDomains uss
SetServerOperators operatorsRoles -> do
ops <- serverOperators <$> withFastStore getServerOperators
ops' <- mapM (updateOp ops) operatorsRoles
processChatCommand $ APISetServerOperators ops'
where
updateOp :: [ServerOperator] -> ServerOperatorRoles -> CM ServerOperator
updateOp ops r =
case find (\ServerOperator {operatorId = DBEntityId opId} -> operatorId' r == opId) ops of
Just op -> pure op {enabled = enabled' r, smpRoles = smpRoles' r, xftpRoles = xftpRoles' r}
Nothing -> throwError $ ChatErrorStore $ SEOperatorNotFound $ operatorId' r
APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> do
CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user)
APISetUserServers userId userServers -> withUserId userId $ \user -> do
errors <- validateAllUsersServers userId $ L.toList userServers
unless (null errors) $ throwChatError (CECommandError $ "user servers validation error(s): " <> show errors)
uss <- withFastStore $ \db -> do
ts <- liftIO getCurrentTime
mapM (setUserServers db user ts) userServers
as <- asks randomAgentServers
lift $ withAgent' $ \a -> do
let auId = aUserId user
opDomains = operatorDomains $ mapMaybe operator' $ L.toList uss
(smp', xftp') = useServers as opDomains uss
setProtocolServers a auId smp'
setProtocolServers a auId xftp'
ok_
APIValidateServers userId userServers -> withUserId userId $ \user ->
CRUserServersValidation user <$> validateAllUsersServers userId userServers
APIGetUsageConditions -> do
(usageConditions, acceptedConditions) <- withFastStore $ \db -> do
usageConditions <- getCurrentUsageConditions db
acceptedConditions <- liftIO $ getLatestAcceptedConditions db
pure (usageConditions, acceptedConditions)
-- TODO if db commit is different from source commit, conditionsText should be nothing in response
pure
CRUsageConditions
{ usageConditions,
conditionsText = usageConditionsText,
acceptedConditions
}
APISetConditionsNotified condId -> do
currentTs <- liftIO getCurrentTime
withFastStore' $ \db -> setConditionsNotified db condId currentTs
ok_
APIAcceptConditions condId opIds -> withFastStore $ \db -> do
currentTs <- liftIO getCurrentTime
acceptConditions db condId opIds currentTs
CRServerOperatorConditions <$> getServerOperators db
APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user ->
checkStoreNotChanged $
withChatLock "setChatItemTTL" $ do
@ -1753,8 +1940,9 @@ processChatCommand' vr = \case
canKeepLink (CRInvitationUri crData _) newUser = do
let ConnReqUriData {crSmpQueues = q :| _} = crData
SMPQueueUri {queueAddress = SMPQueueAddress {smpServer}} = q
cfg <- asks config
newUserServers <- L.map (\ServerCfg {server} -> protoServer server) . useServers cfg SPSMP <$> withFastStore' (`getProtocolServers` newUser)
newUserServers <-
map protoServer' . L.filter (\ServerCfg {enabled} -> enabled)
<$> getKnownAgentServers SPSMP newUser
pure $ smpServer `elem` newUserServers
updateConnRecord user@User {userId} conn@PendingContactConnection {customUserProfileId} newUser = do
withAgent $ \a -> changeConnectionUser a (aUserId user) (aConnId' conn) (aUserId newUser)
@ -2088,7 +2276,7 @@ processChatCommand' vr = \case
where
changeMemberRole user gInfo members m gEvent = do
let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m
assertUserGroupRole gInfo $ maximum [GRAdmin, mRole, memRole]
assertUserGroupRole gInfo $ maximum ([GRAdmin, mRole, memRole] :: [GroupMemberRole])
withGroupLock "memberRole" groupId . procCmd $ do
unless (mRole == memRole) $ do
withFastStore' $ \db -> updateGroupMemberRole db user m memRole
@ -2486,14 +2674,15 @@ processChatCommand' vr = \case
pure $ CRAgentSubsTotal user subsTotal hasSession
GetAgentServersSummary userId -> withUserId userId $ \user -> do
agentServersSummary <- lift $ withAgent' getAgentServersSummary
cfg <- asks config
(users, smpServers, xftpServers) <-
withStore' $ \db -> (,,) <$> getUsers db <*> getServers db cfg user SPSMP <*> getServers db cfg user SPXFTP
let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers _defaultNtfServers
pure $ CRAgentServersSummary user presentedServersSummary
withStore' $ \db -> do
users <- getUsers db
smpServers <- getServers db user SPSMP
xftpServers <- getServers db user SPXFTP
let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers _defaultNtfServers
pure $ CRAgentServersSummary user presentedServersSummary
where
getServers :: (ProtocolTypeI p, UserProtocol p) => DB.Connection -> ChatConfig -> User -> SProtocolType p -> IO (NonEmpty (ProtocolServer p))
getServers db cfg user p = L.map (\ServerCfg {server} -> protoServer server) . useServers cfg p <$> getProtocolServers db user
getServers :: ProtocolTypeI p => DB.Connection -> User -> SProtocolType p -> IO [ProtocolServer p]
getServers db user p = map (\UserServer {server} -> protoServer server) <$> getProtocolServers db p user
ResetAgentServersStats -> withAgent resetAgentServersStats >> ok_
GetAgentWorkers -> lift $ CRAgentWorkersSummary <$> withAgent' getAgentWorkersSummary
GetAgentWorkersDetails -> lift $ CRAgentWorkersDetails <$> withAgent' getAgentWorkersDetails
@ -2784,6 +2973,20 @@ processChatCommand' vr = \case
withServerProtocol p action = case userProtocol p of
Just Dict -> action
_ -> throwChatError $ CEServerProtocol $ AProtocolType p
validateAllUsersServers :: UserServersClass u => Int64 -> [u] -> CM [UserServersError]
validateAllUsersServers currUserId userServers = withFastStore $ \db -> do
users' <- filter (\User {userId} -> userId /= currUserId) <$> liftIO (getUsers db)
others <- mapM (getUserOperatorServers db) users'
pure $ validateUserServers userServers others
where
getUserOperatorServers :: DB.Connection -> User -> ExceptT StoreError IO (User, [UserOperatorServers])
getUserOperatorServers db user = do
uss <- liftIO . groupByOperator =<< getUserServers db user
pure (user, map updatedUserSrvs uss)
updatedUserSrvs uss = uss {operator = updatedOp <$> operator' uss} :: UserOperatorServers
updatedOp op = fromMaybe op $ find matchingOp $ mapMaybe operator' userServers
where
matchingOp op' = operatorId op' == operatorId op
forwardFile :: ChatName -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> CM ChatResponse
forwardFile chatName fileId sendCommand = withUser $ \user -> do
withStore (\db -> getFileTransfer db user fileId) >>= \case
@ -3225,6 +3428,23 @@ processChatCommand' vr = \case
msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId)
CRQueueInfo user msgInfo <$> withAgent (`getConnectionQueueInfo` acId)
protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP])
protocolServers p (operators, smpServers, xftpServers) = case p of
SPSMP -> (operators, smpServers, [])
SPXFTP -> (operators, [], xftpServers)
-- disable preset and replace custom servers (groupByOperator always adds custom)
updatedServers :: forall p. UserProtocol p => SProtocolType p -> [AUserServer p] -> UserOperatorServers -> UpdatedUserOperatorServers
updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = case p' of
SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers)
SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers)
where
u = uncurry $ UpdatedUserOperatorServers operator
updateSrvs :: [UserServer p] -> [AUserServer p]
updateSrvs pSrvs = map disableSrv pSrvs <> maybe srvs (const []) operator
disableSrv srv@UserServer {preset} =
AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True}
type ComposeMessageReq = (ComposedMessage, Maybe CIForwardedFrom)
contactCITimed :: Contact -> CM (Maybe CITimed)
@ -3611,8 +3831,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete}
S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks
getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer]
getUnknownSrvs srvs = do
cfg <- asks config
knownSrvs <- L.map (\ServerCfg {server} -> protoServer server) . useServers cfg SPXFTP <$> withStore' (`getProtocolServers` user)
knownSrvs <- L.map protoServer' <$> getKnownAgentServers SPXFTP user
pure $ filter (`notElem` knownSrvs) srvs
ipProtectedForSrvs :: [XFTPServer] -> CM Bool
ipProtectedForSrvs srvs = do
@ -3626,6 +3845,17 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete}
toView $ CRChatItemUpdated user aci
throwChatError $ CEFileNotApproved fileId unknownSrvs
getKnownAgentServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> User -> CM (NonEmpty (ServerCfg p))
getKnownAgentServers p user = do
as <- asks randomAgentServers
withStore $ \db -> do
opDomains <- operatorDomains . serverOperators <$> getServerOperators db
srvs <- liftIO $ getProtocolServers db p user
pure $ useServerCfgs p as opDomains srvs
protoServer' :: ServerCfg p -> ProtocolServer p
protoServer' ServerCfg {server} = protoServer server
getNetworkConfig :: CM' NetworkConfig
getNetworkConfig = withAgent' $ liftIO . getFastNetworkConfig
@ -3824,7 +4054,7 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do
(sftConns, sfts) <- getSndFileTransferConns
(rftConns, rfts) <- getRcvFileTransferConns
(pcConns, pcs) <- getPendingContactConns
let conns = concat [ctConns, ucConns, mConns, sftConns, rftConns, pcConns]
let conns = concat ([ctConns, ucConns, mConns, sftConns, rftConns, pcConns] :: [[ConnId]])
pure (conns, cts, ucs, gs, ms, sfts, rfts, pcs)
-- subscribe using batched commands
rs <- withAgent $ \a -> agentBatchSubscribe a conns
@ -4632,7 +4862,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct)
SWITCH qd phase cStats -> do
toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats)
when (phase `elem` [SPStarted, SPCompleted]) $ case qd of
when (phase == SPStarted || phase == SPCompleted) $ case qd of
QDRcv -> createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCESwitchQueue phase Nothing) Nothing
QDSnd -> createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing
RSYNC rss cryptoErr_ cStats ->
@ -4917,7 +5147,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
(Just fileDescrText, Just msgId) -> do
partSize <- asks $ xftpDescrPartSize . config
let parts = splitFileDescr partSize fileDescrText
pure . toList $ L.map (XMsgFileDescr msgId) parts
pure . L.toList $ L.map (XMsgFileDescr msgId) parts
_ -> pure []
let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents
GroupMember {memberId} = sender
@ -5043,7 +5273,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
when continued $ sendPendingGroupMessages user m conn
SWITCH qd phase cStats -> do
toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats)
when (phase `elem` [SPStarted, SPCompleted]) $ case qd of
when (phase == SPStarted || phase == SPCompleted) $ case qd of
QDRcv -> createInternalChatItem user (CDGroupSnd gInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing
QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing
RSYNC rss cryptoErr_ cStats ->
@ -6607,15 +6837,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
messageWarning "x.grp.mem.con: neither member is invitee"
where
inviteeXGrpMemCon :: GroupMemberIntro -> CM ()
inviteeXGrpMemCon GroupMemberIntro {introId, introStatus}
| introStatus == GMIntroReConnected = updateStatus introId GMIntroConnected
| introStatus `elem` [GMIntroToConnected, GMIntroConnected] = pure ()
| otherwise = updateStatus introId GMIntroToConnected
inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of
GMIntroReConnected -> updateStatus introId GMIntroConnected
GMIntroToConnected -> pure ()
GMIntroConnected -> pure ()
_ -> updateStatus introId GMIntroToConnected
forwardMemberXGrpMemCon :: GroupMemberIntro -> CM ()
forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus}
| introStatus == GMIntroToConnected = updateStatus introId GMIntroConnected
| introStatus `elem` [GMIntroReConnected, GMIntroConnected] = pure ()
| otherwise = updateStatus introId GMIntroReConnected
forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of
GMIntroToConnected -> updateStatus introId GMIntroConnected
GMIntroReConnected -> pure ()
GMIntroConnected -> pure ()
_ -> updateStatus introId GMIntroReConnected
updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status
xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> CM ()
@ -8080,14 +8312,19 @@ chatCommandP =
"/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP),
"/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP),
"/ntf test " *> (TestProtoServer . AProtoServerWithAuth SPNTF <$> strP),
"/_servers " *> (APISetUserProtoServers <$> A.decimal <* A.space <*> srvCfgP),
"/smp " *> (SetUserProtoServers . APSC SPSMP . ProtoServersConfig . map enabledServerCfg <$> protocolServersP),
"/smp default" $> SetUserProtoServers (APSC SPSMP $ ProtoServersConfig []),
"/xftp " *> (SetUserProtoServers . APSC SPXFTP . ProtoServersConfig . map enabledServerCfg <$> protocolServersP),
"/xftp default" $> SetUserProtoServers (APSC SPXFTP $ ProtoServersConfig []),
"/_servers " *> (APIGetUserProtoServers <$> A.decimal <* A.space <*> strP),
"/smp " *> (SetUserProtoServers (AProtocolType SPSMP) . map (AProtoServerWithAuth SPSMP) <$> protocolServersP),
"/xftp " *> (SetUserProtoServers (AProtocolType SPXFTP) . map (AProtoServerWithAuth SPXFTP) <$> protocolServersP),
"/smp" $> GetUserProtoServers (AProtocolType SPSMP),
"/xftp" $> GetUserProtoServers (AProtocolType SPXFTP),
"/_operators" $> APIGetServerOperators,
"/_operators " *> (APISetServerOperators <$> jsonP),
"/operators " *> (SetServerOperators . L.fromList <$> operatorRolesP `A.sepBy1` A.char ','),
"/_servers " *> (APIGetUserServers <$> A.decimal),
"/_servers " *> (APISetUserServers <$> A.decimal <* A.space <*> jsonP),
"/_validate_servers " *> (APIValidateServers <$> A.decimal <* A.space <*> jsonP),
"/_conditions" $> APIGetUsageConditions,
"/_conditions_notified " *> (APISetConditionsNotified <$> A.decimal),
"/_accept_conditions " *> (APIAcceptConditions <$> A.decimal <*> _strP),
"/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal),
"/ttl " *> (SetChatItemTTL <$> ciTTL),
"/_ttl " *> (APIGetChatItemTTL <$> A.decimal),
@ -8411,6 +8648,20 @@ chatCommandP =
optional ("yes" *> A.space) *> (TMEEnableSetTTL <$> timedTTLP)
<|> ("yes" $> TMEEnableKeepTTL)
<|> ("no" $> TMEDisableKeepTTL)
operatorRolesP = do
operatorId' <- A.decimal
enabled' <- A.char ':' *> onOffP
smpRoles' <- (":smp=" *> srvRolesP) <|> pure allRoles
xftpRoles' <- (":xftp=" *> srvRolesP) <|> pure allRoles
pure ServerOperatorRoles {operatorId', enabled', smpRoles', xftpRoles'}
srvRolesP = srvRoles <$?> A.takeTill (\c -> c == ':' || c == ',')
where
srvRoles = \case
"off" -> Right $ ServerRoles False False
"proxy" -> Right ServerRoles {storage = False, proxy = True}
"storage" -> Right ServerRoles {storage = True, proxy = False}
"on" -> Right allRoles
_ -> Left "bad ServerRoles"
netCfgP = do
socksProxy <- "socks=" *> ("off" $> Nothing <|> "on" $> Just defaultSocksProxyWithAuth <|> Just <$> strP)
socksMode <- " socks-mode=" *> strP <|> pure SMAlways
@ -8431,7 +8682,6 @@ chatCommandP =
onOffP
(Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP)))
(pure Nothing)
srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP)
rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (jsonP <|> text1P))
text1P = safeDecodeUtf8 <$> A.takeTill (== ' ')
char_ = optional . A.char

View file

@ -35,7 +35,6 @@ import qualified Data.ByteArray as BA
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Char (ord)
import Data.Constraint (Dict (..))
import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map)
@ -57,6 +56,7 @@ import Simplex.Chat.Call
import Simplex.Chat.Markdown (MarkdownList)
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Operators
import Simplex.Chat.Protocol
import Simplex.Chat.Remote.AppVersion
import Simplex.Chat.Remote.Types
@ -84,7 +84,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON)
import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), ProtocolTypeI, QueueId, SMPMsgMeta (..), SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol)
import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer)
import Simplex.Messaging.TMap (TMap)
import Simplex.Messaging.Transport (TLS, simplexMQVersion)
import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost)
@ -132,7 +132,7 @@ data ChatConfig = ChatConfig
{ agentConfig :: AgentConfig,
chatVRange :: VersionRangeChat,
confirmMigrations :: MigrationConfirmation,
defaultServers :: DefaultAgentServers,
presetServers :: PresetServers,
tbqSize :: Natural,
fileChunkSize :: Integer,
xftpDescrPartSize :: Int,
@ -154,6 +154,12 @@ data ChatConfig = ChatConfig
chatHooks :: ChatHooks
}
data RandomAgentServers = RandomAgentServers
{ smpServers :: NonEmpty (ServerCfg 'PSMP),
xftpServers :: NonEmpty (ServerCfg 'PXFTP)
}
deriving (Show)
-- The hooks can be used to extend or customize chat core in mobile or CLI clients.
data ChatHooks = ChatHooks
{ -- preCmdHook can be used to process or modify the commands before they are processed.
@ -172,14 +178,12 @@ defaultChatHooks =
eventHook = \_ -> pure
}
data DefaultAgentServers = DefaultAgentServers
{ smp :: NonEmpty (ServerCfg 'PSMP),
useSMP :: Int,
data PresetServers = PresetServers
{ operators :: NonEmpty PresetOperator,
ntf :: [NtfServer],
xftp :: NonEmpty (ServerCfg 'PXFTP),
useXFTP :: Int,
netCfg :: NetworkConfig
}
deriving (Show)
data InlineFilesConfig = InlineFilesConfig
{ offerChunks :: Integer,
@ -203,6 +207,8 @@ data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLite
data ChatController = ChatController
{ currentUser :: TVar (Maybe User),
randomPresetServers :: NonEmpty PresetOperator,
randomAgentServers :: RandomAgentServers,
currentRemoteHost :: TVar (Maybe RemoteHostId),
firstTime :: Bool,
smpAgent :: AgentClient,
@ -346,12 +352,19 @@ data ChatCommand
| APIGetGroupLink GroupId
| APICreateMemberContact GroupId GroupMemberId
| APISendMemberContactInvitation {contactId :: ContactId, msgContent_ :: Maybe MsgContent}
| APIGetUserProtoServers UserId AProtocolType
| GetUserProtoServers AProtocolType
| APISetUserProtoServers UserId AProtoServersConfig
| SetUserProtoServers AProtoServersConfig
| SetUserProtoServers AProtocolType [AProtoServerWithAuth]
| APITestProtoServer UserId AProtoServerWithAuth
| TestProtoServer AProtoServerWithAuth
| APIGetServerOperators
| APISetServerOperators (NonEmpty ServerOperator)
| SetServerOperators (NonEmpty ServerOperatorRoles)
| APIGetUserServers UserId
| APISetUserServers UserId (NonEmpty UpdatedUserOperatorServers)
| APIValidateServers UserId [UpdatedUserOperatorServers] -- response is CRUserServersValidation
| APIGetUsageConditions
| APISetConditionsNotified Int64
| APIAcceptConditions Int64 (NonEmpty Int64)
| APISetChatItemTTL UserId (Maybe Int64)
| SetChatItemTTL (Maybe Int64)
| APIGetChatItemTTL UserId
@ -577,8 +590,11 @@ data ChatResponse
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
| CRChatItemId User (Maybe ChatItemId)
| CRApiParsedMarkdown {formattedText :: Maybe MarkdownList}
| CRUserProtoServers {user :: User, servers :: AUserProtoServers}
| CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure}
| CRServerOperatorConditions {conditions :: ServerOperatorConditions}
| CRUserServers {user :: User, userServers :: [UserOperatorServers]}
| CRUserServersValidation {user :: User, serverErrors :: [UserServersError]}
| CRUsageConditions {usageConditions :: UsageConditions, conditionsText :: Text, acceptedConditions :: Maybe UsageConditions}
| CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64}
| CRNetworkConfig {networkConfig :: NetworkConfig}
| CRContactInfo {user :: User, contact :: Contact, connectionStats_ :: Maybe ConnectionStats, customUserProfile :: Maybe Profile}
@ -943,24 +959,6 @@ instance ToJSON AgentQueueId where
toJSON = strToJSON
toEncoding = strToJEncoding
data ProtoServersConfig p = ProtoServersConfig {servers :: [ServerCfg p]}
deriving (Show)
data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) (ProtoServersConfig p)
deriving instance Show AProtoServersConfig
data UserProtoServers p = UserProtoServers
{ serverProtocol :: SProtocolType p,
protoServers :: NonEmpty (ServerCfg p),
presetServers :: NonEmpty (ServerCfg p)
}
deriving (Show)
data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p)
deriving instance Show AUserProtoServers
data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath}
deriving (Show)
@ -1562,28 +1560,28 @@ $(JQ.deriveJSON defaultJSON ''CoreVersionInfo)
$(JQ.deriveJSON defaultJSON ''SlowSQLQuery)
instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where
parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig)
-- instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where
-- parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig)
instance ProtocolTypeI p => FromJSON (UserProtoServers p) where
parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers)
-- instance ProtocolTypeI p => FromJSON (UserProtoServers p) where
-- parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers)
instance ProtocolTypeI p => ToJSON (UserProtoServers p) where
toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers)
toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers)
-- instance ProtocolTypeI p => ToJSON (UserProtoServers p) where
-- toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers)
-- toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers)
instance FromJSON AUserProtoServers where
parseJSON v = J.withObject "AUserProtoServers" parse v
where
parse o = do
AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol"
case userProtocol p of
Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v
Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p
-- instance FromJSON AUserProtoServers where
-- parseJSON v = J.withObject "AUserProtoServers" parse v
-- where
-- parse o = do
-- AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol"
-- case userProtocol p of
-- Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v
-- Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p
instance ToJSON AUserProtoServers where
toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s
toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s
-- instance ToJSON AUserProtoServers where
-- toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s
-- toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState)

View file

@ -0,0 +1,58 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20241027_server_operators where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20241027_server_operators :: Query
m20241027_server_operators =
[sql|
CREATE TABLE server_operators (
server_operator_id INTEGER PRIMARY KEY AUTOINCREMENT,
server_operator_tag TEXT,
trade_name TEXT NOT NULL,
legal_name TEXT,
server_domains TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
smp_role_storage INTEGER NOT NULL DEFAULT 1,
smp_role_proxy INTEGER NOT NULL DEFAULT 1,
xftp_role_storage INTEGER NOT NULL DEFAULT 1,
xftp_role_proxy INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE usage_conditions (
usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT,
conditions_commit TEXT NOT NULL UNIQUE,
notified_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE operator_usage_conditions (
operator_usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT,
server_operator_id INTEGER REFERENCES server_operators (server_operator_id) ON DELETE SET NULL ON UPDATE CASCADE,
server_operator_tag TEXT,
conditions_commit TEXT NOT NULL,
accepted_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_conditions(server_operator_id);
CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions(conditions_commit, server_operator_id);
|]
down_m20241027_server_operators :: Query
down_m20241027_server_operators =
[sql|
DROP INDEX idx_operator_usage_conditions_conditions_commit;
DROP INDEX idx_operator_usage_conditions_server_operator_id;
DROP TABLE operator_usage_conditions;
DROP TABLE usage_conditions;
DROP TABLE server_operators;
DELETE FROM protocol_servers WHERE host LIKE "%.simplexonflux.com,%";
|]

View file

@ -589,6 +589,35 @@ CREATE TABLE note_folders(
unread_chat INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE app_settings(app_settings TEXT NOT NULL);
CREATE TABLE server_operators(
server_operator_id INTEGER PRIMARY KEY AUTOINCREMENT,
server_operator_tag TEXT,
trade_name TEXT NOT NULL,
legal_name TEXT,
server_domains TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
smp_role_storage INTEGER NOT NULL DEFAULT 1,
smp_role_proxy INTEGER NOT NULL DEFAULT 1,
xftp_role_storage INTEGER NOT NULL DEFAULT 1,
xftp_role_proxy INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE usage_conditions(
usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT,
conditions_commit TEXT NOT NULL UNIQUE,
notified_at TEXT,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE operator_usage_conditions(
operator_usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT,
server_operator_id INTEGER REFERENCES server_operators(server_operator_id) ON DELETE SET NULL ON UPDATE CASCADE,
server_operator_tag TEXT,
conditions_commit TEXT NOT NULL,
accepted_at TEXT,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
full_name
@ -890,3 +919,10 @@ CREATE INDEX idx_received_probes_group_member_id on received_probes(
group_member_id
);
CREATE INDEX idx_contact_requests_contact_id ON contact_requests(contact_id);
CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_conditions(
server_operator_id
);
CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions(
conditions_commit,
server_operator_id
);

View file

@ -0,0 +1,523 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilyDependencies #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Operators where
import Control.Applicative ((<|>))
import Data.Aeson (FromJSON (..), ToJSON (..))
import qualified Data.Aeson as J
import qualified Data.Aeson.Encoding as JE
import qualified Data.Aeson.TH as JQ
import Data.FileEmbed
import Data.Foldable (foldMap')
import Data.Functor.Identity
import Data.IORef
import Data.Int (Int64)
import Data.Kind
import Data.List (find, foldl')
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isNothing, mapMaybe)
import Data.Scientific (floatingOrInteger)
import Data.Set (Set)
import qualified Data.Set as S
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time (addUTCTime)
import Data.Time.Clock (UTCTime, nominalDay)
import Database.SQLite.Simple.FromField (FromField (..))
import Database.SQLite.Simple.ToField (ToField (..))
import Language.Haskell.TH.Syntax (lift)
import Simplex.Chat.Operators.Conditions
import Simplex.Chat.Types (User)
import Simplex.Chat.Types.Util (textParseJSON)
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON)
import Simplex.Messaging.Protocol (AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol)
import Simplex.Messaging.Transport.Client (TransportHost (..))
import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8)
usageConditionsCommit :: Text
usageConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03"
previousConditionsCommit :: Text
previousConditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c"
usageConditionsText :: Text
usageConditionsText =
$( let s = $(embedFile =<< makeRelativeToProject "PRIVACY.md")
in [|stripFrontMatter $(lift (safeDecodeUtf8 s))|]
)
data DBStored = DBStored | DBNew
data SDBStored (s :: DBStored) where
SDBStored :: SDBStored 'DBStored
SDBNew :: SDBStored 'DBNew
deriving instance Show (SDBStored s)
class DBStoredI s where sdbStored :: SDBStored s
instance DBStoredI 'DBStored where sdbStored = SDBStored
instance DBStoredI 'DBNew where sdbStored = SDBNew
data DBEntityId' (s :: DBStored) where
DBEntityId :: Int64 -> DBEntityId' 'DBStored
DBNewEntity :: DBEntityId' 'DBNew
deriving instance Show (DBEntityId' s)
deriving instance Eq (DBEntityId' s)
type DBEntityId = DBEntityId' 'DBStored
type DBNewEntity = DBEntityId' 'DBNew
data OperatorTag = OTSimplex | OTFlux
deriving (Eq, Ord, Show)
instance FromField OperatorTag where fromField = fromTextField_ textDecode
instance ToField OperatorTag where toField = toField . textEncode
instance FromJSON OperatorTag where
parseJSON = textParseJSON "OperatorTag"
instance ToJSON OperatorTag where
toJSON = J.String . textEncode
toEncoding = JE.text . textEncode
instance TextEncoding OperatorTag where
textDecode = \case
"simplex" -> Just OTSimplex
"flux" -> Just OTFlux
_ -> Nothing
textEncode = \case
OTSimplex -> "simplex"
OTFlux -> "flux"
-- this and other types only define instances of serialization for known DB IDs only,
-- entities without IDs cannot be serialized to JSON
instance FromField DBEntityId where fromField f = DBEntityId <$> fromField f
instance ToField DBEntityId where toField (DBEntityId i) = toField i
data UsageConditions = UsageConditions
{ conditionsId :: Int64,
conditionsCommit :: Text,
notifiedAt :: Maybe UTCTime,
createdAt :: UTCTime
}
deriving (Show)
data UsageConditionsAction
= UCAReview {operators :: [ServerOperator], deadline :: Maybe UTCTime, showNotice :: Bool}
| UCAAccepted {operators :: [ServerOperator]}
deriving (Show)
data ServerOperatorConditions = ServerOperatorConditions
{ serverOperators :: [ServerOperator],
currentConditions :: UsageConditions,
conditionsAction :: Maybe UsageConditionsAction
}
deriving (Show)
usageConditionsAction :: [ServerOperator] -> UsageConditions -> UTCTime -> Maybe UsageConditionsAction
usageConditionsAction operators UsageConditions {createdAt, notifiedAt} now = do
let enabledOperators = filter (\ServerOperator {enabled} -> enabled) operators
if
| null enabledOperators -> Nothing
| all conditionsAccepted enabledOperators ->
let acceptedForOperators = filter conditionsAccepted operators
in Just $ UCAAccepted acceptedForOperators
| otherwise ->
let acceptForOperators = filter (not . conditionsAccepted) enabledOperators
deadline = conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt)
showNotice = isNothing notifiedAt
in Just $ UCAReview acceptForOperators deadline showNotice
conditionsRequiredOrDeadline :: UTCTime -> UTCTime -> Maybe UTCTime
conditionsRequiredOrDeadline createdAt notifiedAtOrNow =
if notifiedAtOrNow < addUTCTime (14 * nominalDay) createdAt
then Just $ conditionsDeadline notifiedAtOrNow
else Nothing -- required
where
conditionsDeadline :: UTCTime -> UTCTime
conditionsDeadline = addUTCTime (31 * nominalDay)
data ConditionsAcceptance
= CAAccepted {acceptedAt :: Maybe UTCTime}
| CARequired {deadline :: Maybe UTCTime}
deriving (Show)
type ServerOperator = ServerOperator' 'DBStored
type NewServerOperator = ServerOperator' 'DBNew
data AServerOperator = forall s. ASO (SDBStored s) (ServerOperator' s)
deriving instance Show AServerOperator
data ServerOperator' s = ServerOperator
{ operatorId :: DBEntityId' s,
operatorTag :: Maybe OperatorTag,
tradeName :: Text,
legalName :: Maybe Text,
serverDomains :: [Text],
conditionsAcceptance :: ConditionsAcceptance,
enabled :: Bool,
smpRoles :: ServerRoles,
xftpRoles :: ServerRoles
}
deriving (Show)
data ServerOperatorRoles = ServerOperatorRoles
{ operatorId' :: Int64,
enabled' :: Bool,
smpRoles' :: ServerRoles,
xftpRoles' :: ServerRoles
}
deriving (Show)
operatorRoles :: UserProtocol p => SProtocolType p -> ServerOperator -> ServerRoles
operatorRoles p op = case p of
SPSMP -> smpRoles op
SPXFTP -> xftpRoles op
conditionsAccepted :: ServerOperator -> Bool
conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAcceptance of
CAAccepted {} -> True
_ -> False
data UserOperatorServers = UserOperatorServers
{ operator :: Maybe ServerOperator,
smpServers :: [UserServer 'PSMP],
xftpServers :: [UserServer 'PXFTP]
}
deriving (Show)
data UpdatedUserOperatorServers = UpdatedUserOperatorServers
{ operator :: Maybe ServerOperator,
smpServers :: [AUserServer 'PSMP],
xftpServers :: [AUserServer 'PXFTP]
}
deriving (Show)
data ValidatedProtoServer p = ValidatedProtoServer {unVPS :: Either Text (ProtoServerWithAuth p)}
deriving (Show)
class UserServersClass u where
type AServer u = (s :: ProtocolType -> Type) | s -> u
operator' :: u -> Maybe ServerOperator
aUserServer' :: AServer u p -> AUserServer p
servers' :: UserProtocol p => SProtocolType p -> u -> [AServer u p]
instance UserServersClass UserOperatorServers where
type AServer UserOperatorServers = UserServer' 'DBStored
operator' UserOperatorServers {operator} = operator
aUserServer' = AUS SDBStored
servers' p UserOperatorServers {smpServers, xftpServers} = case p of
SPSMP -> smpServers
SPXFTP -> xftpServers
instance UserServersClass UpdatedUserOperatorServers where
type AServer UpdatedUserOperatorServers = AUserServer
operator' UpdatedUserOperatorServers {operator} = operator
aUserServer' = id
servers' p UpdatedUserOperatorServers {smpServers, xftpServers} = case p of
SPSMP -> smpServers
SPXFTP -> xftpServers
type UserServer p = UserServer' 'DBStored p
type NewUserServer p = UserServer' 'DBNew p
data AUserServer p = forall s. AUS (SDBStored s) (UserServer' s p)
deriving instance Show (AUserServer p)
data UserServer' s (p :: ProtocolType) = UserServer
{ serverId :: DBEntityId' s,
server :: ProtoServerWithAuth p,
preset :: Bool,
tested :: Maybe Bool,
enabled :: Bool,
deleted :: Bool
}
deriving (Show)
data PresetOperator = PresetOperator
{ operator :: Maybe NewServerOperator,
smp :: [NewUserServer 'PSMP],
useSMP :: Int,
xftp :: [NewUserServer 'PXFTP],
useXFTP :: Int
}
deriving (Show)
pOperator :: PresetOperator -> Maybe NewServerOperator
pOperator PresetOperator {operator} = operator
pServers :: UserProtocol p => SProtocolType p -> PresetOperator -> [NewUserServer p]
pServers p PresetOperator {smp, xftp} = case p of
SPSMP -> smp
SPXFTP -> xftp
operatorServersToUse :: UserProtocol p => SProtocolType p -> PresetOperator -> Int
operatorServersToUse p PresetOperator {useSMP, useXFTP} = case p of
SPSMP -> useSMP
SPXFTP -> useXFTP
presetServer :: Bool -> ProtoServerWithAuth p -> NewUserServer p
presetServer = newUserServer_ True
newUserServer :: ProtoServerWithAuth p -> NewUserServer p
newUserServer = newUserServer_ False True
newUserServer_ :: Bool -> Bool -> ProtoServerWithAuth p -> NewUserServer p
newUserServer_ preset enabled server =
UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False}
-- This function should be used inside DB transaction to update conditions in the database
-- it evaluates to (conditions to mark as accepted to SimpleX operator, current conditions, and conditions to add)
usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions])
usageConditionsToAdd = usageConditionsToAdd' previousConditionsCommit usageConditionsCommit
-- This function is used in unit tests
usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions])
usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case
[]
| newUser -> (Just sourceCond, sourceCond, [sourceCond])
| otherwise -> (Just prevCond, sourceCond, [prevCond, sourceCond])
where
prevCond = conditions 1 prevCommit
sourceCond = conditions 2 sourceCommit
conds
| hasSourceCond -> (Nothing, last conds, [])
| otherwise -> (Nothing, sourceCond, [sourceCond])
where
hasSourceCond = any ((sourceCommit ==) . conditionsCommit) conds
sourceCond = conditions cId sourceCommit
cId = maximum (map conditionsId conds) + 1
where
conditions cId commit = UsageConditions {conditionsId = cId, conditionsCommit = commit, notifiedAt = Nothing, createdAt}
presetUserServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [UpdatedUserOperatorServers]
presetUserServers = mapMaybe $ \(presetOp_, op) -> mkUS op <$> presetOp_
where
mkUS op PresetOperator {smp, xftp} =
UpdatedUserOperatorServers op (map (AUS SDBNew) smp) (map (AUS SDBNew) xftp)
-- This function should be used inside DB transaction to update operators.
-- It allows to add/remove/update preset operators in the database preserving enabled and roles settings,
-- and preserves custom operators without tags for forward compatibility.
updatedServerOperators :: NonEmpty PresetOperator -> [ServerOperator] -> [(Maybe PresetOperator, Maybe AServerOperator)]
updatedServerOperators presetOps storedOps =
foldr addPreset [] presetOps
<> map (\op -> (Nothing, Just $ ASO SDBStored op)) (filter (isNothing . operatorTag) storedOps)
where
-- TODO remove domains of preset operators from custom
addPreset op = ((Just op, storedOp' <$> pOperator op) :)
where
storedOp' presetOp = case find ((operatorTag presetOp ==) . operatorTag) storedOps of
Just ServerOperator {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles} ->
ASO SDBStored presetOp {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles}
Nothing -> ASO SDBNew presetOp
-- This function should be used inside DB transaction to update servers.
updatedUserServers :: (Maybe PresetOperator, UserOperatorServers) -> UpdatedUserOperatorServers
updatedUserServers (presetOp_, UserOperatorServers {operator, smpServers, xftpServers}) =
UpdatedUserOperatorServers {operator, smpServers = smp', xftpServers = xftp'}
where
stored = map (AUS SDBStored)
(smp', xftp') = case presetOp_ of
Nothing -> (stored smpServers, stored xftpServers)
Just presetOp -> (updated SPSMP smpServers, updated SPXFTP xftpServers)
where
updated :: forall p. UserProtocol p => SProtocolType p -> [UserServer p] -> [AUserServer p]
updated p srvs = map userServer presetSrvs <> stored (filter customServer srvs)
where
storedSrvs :: Map (ProtoServerWithAuth p) (UserServer p)
storedSrvs = foldl' (\ss srv@UserServer {server} -> M.insert server srv ss) M.empty srvs
customServer :: UserServer p -> Bool
customServer srv = not (preset srv) && all (`S.notMember` presetHosts) (srvHost srv)
presetSrvs :: [NewUserServer p]
presetSrvs = pServers p presetOp
presetHosts :: Set TransportHost
presetHosts = foldMap' (S.fromList . L.toList . srvHost) presetSrvs
userServer :: NewUserServer p -> AUserServer p
userServer srv@UserServer {server} = maybe (AUS SDBNew srv) (AUS SDBStored) (M.lookup server storedSrvs)
srvHost :: UserServer' s p -> NonEmpty TransportHost
srvHost UserServer {server = ProtoServerWithAuth srv _} = host srv
agentServerCfgs :: UserProtocol p => SProtocolType p -> [(Text, ServerOperator)] -> [UserServer' s p] -> [ServerCfg p]
agentServerCfgs p opDomains = mapMaybe agentServer
where
agentServer :: UserServer' s p -> Maybe (ServerCfg p)
agentServer srv@UserServer {server, enabled} =
case find (\(d, _) -> any (matchingHost d) (srvHost srv)) opDomains of
Just (_, op@ServerOperator {operatorId = DBEntityId opId, enabled = opEnabled})
| opEnabled -> Just ServerCfg {server, enabled, operator = Just opId, roles = operatorRoles p op}
| otherwise -> Nothing
Nothing ->
Just ServerCfg {server, enabled, operator = Nothing, roles = allRoles}
matchingHost :: Text -> TransportHost -> Bool
matchingHost d = \case
THDomainName h -> d `T.isSuffixOf` T.pack h
_ -> False
operatorDomains :: [ServerOperator' s] -> [(Text, ServerOperator' s)]
operatorDomains = foldr (\op ds -> foldr (\d -> ((d, op) :)) ds (serverDomains op)) []
class Box b where
box :: a -> b a
unbox :: b a -> a
instance Box Identity where
box = Identity
unbox = runIdentity
instance Box ((,) (Maybe a)) where
box = (Nothing,)
unbox = snd
groupByOperator :: ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [UserOperatorServers]
groupByOperator (ops, smpSrvs, xftpSrvs) = map runIdentity <$> groupByOperator_ (map Identity ops, smpSrvs, xftpSrvs)
-- For the initial app start this function relies on tuple being Functor/Box
-- to preserve the information about operator being DBNew or DBStored
groupByOperator' :: ([(Maybe PresetOperator, Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [(Maybe PresetOperator, UserOperatorServers)]
groupByOperator' = groupByOperator_
{-# INLINE groupByOperator' #-}
groupByOperator_ :: forall f. (Box f, Traversable f) => ([f (Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [f UserOperatorServers]
groupByOperator_ (ops, smpSrvs, xftpSrvs) = do
let ops' = mapMaybe sequence ops
customOp_ = find (isNothing . unbox) ops
ss <- mapM ((\op -> (serverDomains (unbox op),) <$> newIORef (mkUS . Just <$> op))) ops'
custom <- newIORef $ maybe (box $ mkUS Nothing) (mkUS <$>) customOp_
mapM_ (addServer ss custom addSMP) (reverse smpSrvs)
mapM_ (addServer ss custom addXFTP) (reverse xftpSrvs)
opSrvs <- mapM (readIORef . snd) ss
customSrvs <- readIORef custom
pure $ opSrvs <> [customSrvs]
where
mkUS op = UserOperatorServers op [] []
addServer :: [([Text], IORef (f UserOperatorServers))] -> IORef (f UserOperatorServers) -> (UserServer p -> UserOperatorServers -> UserOperatorServers) -> UserServer p -> IO ()
addServer ss custom add srv =
let v = maybe custom snd $ find (\(ds, _) -> any (\d -> any (matchingHost d) (srvHost srv)) ds) ss
in atomicModifyIORef'_ v (add srv <$>)
addSMP srv s@UserOperatorServers {smpServers} = (s :: UserOperatorServers) {smpServers = srv : smpServers}
addXFTP srv s@UserOperatorServers {xftpServers} = (s :: UserOperatorServers) {xftpServers = srv : xftpServers}
data UserServersError
= USENoServers {protocol :: AProtocolType, user :: Maybe User}
| USEStorageMissing {protocol :: AProtocolType, user :: Maybe User}
| USEProxyMissing {protocol :: AProtocolType, user :: Maybe User}
| USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: Text, duplicateHost :: TransportHost}
deriving (Show)
validateUserServers :: UserServersClass u' => [u'] -> [(User, [UserOperatorServers])] -> [UserServersError]
validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others
where
currUserErrs = noServersErrs SPSMP Nothing curr <> noServersErrs SPXFTP Nothing curr <> serverErrs SPSMP curr <> serverErrs SPXFTP curr
otherUserErrs (user, uss) = noServersErrs SPSMP (Just user) uss <> noServersErrs SPXFTP (Just user) uss
noServersErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> Maybe User -> [u] -> [UserServersError]
noServersErrs p user uss
| noServers opEnabled = [USENoServers p' user]
| otherwise = [USEStorageMissing p' user | noServers (hasRole storage)] <> [USEProxyMissing p' user | noServers (hasRole proxy)]
where
p' = AProtocolType p
noServers cond = not $ any srvEnabled $ userServers p $ filter cond uss
opEnabled = maybe True (\ServerOperator {enabled} -> enabled) . operator'
hasRole roleSel = maybe True (\op@ServerOperator {enabled} -> enabled && roleSel (operatorRoles p op)) . operator'
srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted
serverErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [u] -> [UserServersError]
serverErrs p uss = mapMaybe duplicateErr_ srvs
where
p' = AProtocolType p
srvs = filter (\(AUS _ UserServer {deleted}) -> not deleted) $ userServers p uss
duplicateErr_ (AUS _ srv@UserServer {server}) =
USEDuplicateServer p' (safeDecodeUtf8 $ strEncode server)
<$> find (`S.member` duplicateHosts) (srvHost srv)
duplicateHosts = snd $ foldl' addHost (S.empty, S.empty) allHosts
allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs
addHost (hs, dups) h
| h `S.member` hs = (hs, S.insert h dups)
| otherwise = (S.insert h hs, dups)
userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p]
userServers p = map aUserServer' . concatMap (servers' p)
instance ToJSON (DBEntityId' s) where
toEncoding = \case
DBEntityId i -> toEncoding i
DBNewEntity -> JE.null_
toJSON = \case
DBEntityId i -> toJSON i
DBNewEntity -> J.Null
instance DBStoredI s => FromJSON (DBEntityId' s) where
parseJSON v = case (v, sdbStored @s) of
(J.Null, SDBNew) -> pure DBNewEntity
(J.Number n, SDBStored) -> case floatingOrInteger n of
Left (_ :: Double) -> fail "bad DBEntityId"
Right i -> pure $ DBEntityId (fromInteger i)
_ -> fail "bad DBEntityId"
omittedField = case sdbStored @s of
SDBStored -> Nothing
SDBNew -> Just DBNewEntity
$(JQ.deriveJSON defaultJSON ''UsageConditions)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CA") ''ConditionsAcceptance)
instance ToJSON (ServerOperator' s) where
toEncoding = $(JQ.mkToEncoding defaultJSON ''ServerOperator')
toJSON = $(JQ.mkToJSON defaultJSON ''ServerOperator')
instance DBStoredI s => FromJSON (ServerOperator' s) where
parseJSON = $(JQ.mkParseJSON defaultJSON ''ServerOperator')
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction)
$(JQ.deriveJSON defaultJSON ''ServerOperatorConditions)
instance ProtocolTypeI p => ToJSON (UserServer' s p) where
toEncoding = $(JQ.mkToEncoding defaultJSON ''UserServer')
toJSON = $(JQ.mkToJSON defaultJSON ''UserServer')
instance (DBStoredI s, ProtocolTypeI p) => FromJSON (UserServer' s p) where
parseJSON = $(JQ.mkParseJSON defaultJSON ''UserServer')
instance ProtocolTypeI p => FromJSON (AUserServer p) where
parseJSON v = (AUS SDBStored <$> parseJSON v) <|> (AUS SDBNew <$> parseJSON v)
$(JQ.deriveJSON defaultJSON ''UserOperatorServers)
instance FromJSON UpdatedUserOperatorServers where
parseJSON = $(JQ.mkParseJSON defaultJSON ''UpdatedUserOperatorServers)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError)

View file

@ -0,0 +1,19 @@
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Operators.Conditions where
import Data.Char (isSpace)
import Data.Text (Text)
import qualified Data.Text as T
stripFrontMatter :: Text -> Text
stripFrontMatter =
T.unlines
-- . dropWhile ("# " `T.isPrefixOf`) -- strip title
. dropWhile (T.all isSpace)
. dropWhile fm
. (\ls -> let ls' = dropWhile (not . fm) ls in if null ls' then ls else ls')
. dropWhile fm
. T.lines
where
fm = ("---" `T.isPrefixOf`)

View file

@ -236,7 +236,7 @@ coreChatOptsP appDir defaultDbFileName = do
)
yesToUpMigrations <-
switch
( long "--yes-migrate"
( long "yes-migrate"
<> short 'y'
<> help "Automatically confirm \"up\" database migrations"
)

View file

@ -7,7 +7,6 @@ module Simplex.Chat.Stats where
import qualified Data.Aeson.TH as J
import Data.List (partition)
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust)
@ -131,7 +130,7 @@ data NtfServerSummary = NtfServerSummary
-- - users are passed to exclude hidden users from totalServersSummary;
-- - if currentUser is hidden, it should be accounted in totalServersSummary;
-- - known is set only in user level summaries based on passed userSMPSrvs and userXFTPSrvs
toPresentedServersSummary :: AgentServersSummary -> [User] -> User -> NonEmpty SMPServer -> NonEmpty XFTPServer -> [NtfServer] -> PresentedServersSummary
toPresentedServersSummary :: AgentServersSummary -> [User] -> User -> [SMPServer] -> [XFTPServer] -> [NtfServer] -> PresentedServersSummary
toPresentedServersSummary agentSummary users currentUser userSMPSrvs userXFTPSrvs userNtfSrvs = do
let (userSMPSrvsSumms, allSMPSrvsSumms) = accSMPSrvsSummaries
(userSMPCurr, userSMPPrev, userSMPProx) = smpSummsIntoCategories userSMPSrvsSumms

View file

@ -115,6 +115,7 @@ import Simplex.Chat.Migrations.M20240920_user_order
import Simplex.Chat.Migrations.M20241008_indexes
import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id
import Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id
import Simplex.Chat.Migrations.M20241027_server_operators
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@ -229,7 +230,8 @@ schemaMigrations =
("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order),
("20241008_indexes", m20241008_indexes, Just down_m20241008_indexes),
("20241010_contact_requests_contact_id", m20241010_contact_requests_contact_id, Just down_m20241010_contact_requests_contact_id),
("20241023_chat_item_autoincrement_id", m20241023_chat_item_autoincrement_id, Just down_m20241023_chat_item_autoincrement_id)
("20241023_chat_item_autoincrement_id", m20241023_chat_item_autoincrement_id, Just down_m20241023_chat_item_autoincrement_id),
("20241027_server_operators", m20241027_server_operators, Just down_m20241027_server_operators)
]
-- | The list of migrations in ascending order by date

View file

@ -1,5 +1,8 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
@ -47,7 +50,17 @@ module Simplex.Chat.Store.Profiles
getContactWithoutConnViaAddress,
updateUserAddressAutoAccept,
getProtocolServers,
overwriteProtocolServers,
insertProtocolServer,
getUpdateServerOperators,
getServerOperators,
getUserServers,
setServerOperators,
getCurrentUsageConditions,
getLatestAcceptedConditions,
setConditionsNotified,
acceptConditions,
setUserServers,
setUserServers',
createCall,
deleteCalls,
getCalls,
@ -68,14 +81,16 @@ import Data.Functor (($>))
import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe)
import Data.Maybe (catMaybes, fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..))
import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..))
import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Call
import Simplex.Chat.Messages
import Simplex.Chat.Operators
import Simplex.Chat.Protocol
import Simplex.Chat.Store.Direct
import Simplex.Chat.Store.Shared
@ -83,7 +98,7 @@ import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..))
import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..))
import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
@ -91,7 +106,7 @@ import qualified Simplex.Messaging.Crypto as C
import qualified Simplex.Messaging.Crypto.Ratchet as CR
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON)
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode)
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode)
import Simplex.Messaging.Transport.Client (TransportHost)
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8)
@ -515,42 +530,285 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do
Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply)
_ -> (False, False, Nothing)
getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> IO [ServerCfg p]
getProtocolServers db User {userId} =
map toServerCfg
getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> IO [UserServer p]
getProtocolServers db p User {userId} =
map toUserServer
<$> DB.query
db
[sql|
SELECT host, port, key_hash, basic_auth, preset, tested, enabled
SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled
FROM protocol_servers
WHERE user_id = ? AND protocol = ?;
WHERE user_id = ? AND protocol = ?
|]
(userId, decodeLatin1 $ strEncode protocol)
(userId, decodeLatin1 $ strEncode p)
where
protocol = protocolTypeI @p
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p
toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) =
let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
in ServerCfg {server, preset, tested, enabled}
toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> UserServer p
toUserServer (serverId, host, port, keyHash, auth_, preset, tested, enabled) =
let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
in UserServer {serverId, server, preset, tested, enabled, deleted = False}
overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO ()
overwriteProtocolServers db User {userId} servers =
checkConstraint SEUniqueID . ExceptT $ do
currentTs <- getCurrentTime
DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND protocol = ? " (userId, protocol)
forM_ servers $ \ServerCfg {server, preset, tested, enabled} -> do
let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server
insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p)
insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled} = do
DB.execute
db
[sql|
INSERT INTO protocol_servers
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|]
(serverColumns p server :. (preset, tested, enabled, userId, ts, ts))
sId <- insertedRowId db
pure (srv :: NewUserServer p) {serverId = DBEntityId sId}
updateProtocolServer :: ProtocolTypeI p => DB.Connection -> SProtocolType p -> UTCTime -> UserServer p -> IO ()
updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabled} =
DB.execute
db
[sql|
UPDATE protocol_servers
SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?,
preset = ?, tested = ?, enabled = ?, updated_at = ?
WHERE smp_server_id = ?
|]
(serverColumns p server :. (preset, tested, enabled, ts, serverId))
serverColumns :: ProtocolTypeI p => SProtocolType p -> ProtoServerWithAuth p -> (Text, NonEmpty TransportHost, String, C.KeyHash, Maybe Text)
serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) =
let protocol = decodeLatin1 $ strEncode p
auth = safeDecodeUtf8 . unBasicAuth <$> auth_
in (protocol, host, port, keyHash, auth)
getServerOperators :: DB.Connection -> ExceptT StoreError IO ServerOperatorConditions
getServerOperators db = do
currentConditions <- getCurrentUsageConditions db
liftIO $ do
now <- getCurrentTime
latestAcceptedConds_ <- getLatestAcceptedConditions db
let getConds op = (\ca -> op {conditionsAcceptance = ca}) <$> getOperatorConditions_ db op currentConditions latestAcceptedConds_ now
ops <- mapM getConds =<< getServerOperators_ db
let conditionsAction = usageConditionsAction ops currentConditions now
pure ServerOperatorConditions {serverOperators = ops, currentConditions, conditionsAction}
getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP])
getUserServers db user =
(,,)
<$> (map Just . serverOperators <$> getServerOperators db)
<*> liftIO (getProtocolServers db SPSMP user)
<*> liftIO (getProtocolServers db SPXFTP user)
setServerOperators :: DB.Connection -> NonEmpty ServerOperator -> IO ()
setServerOperators db ops = do
currentTs <- getCurrentTime
mapM_ (updateServerOperator db currentTs) ops
updateServerOperator :: DB.Connection -> UTCTime -> ServerOperator -> IO ()
updateServerOperator db currentTs ServerOperator {operatorId, enabled, smpRoles, xftpRoles} =
DB.execute
db
[sql|
UPDATE server_operators
SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ?
WHERE server_operator_id = ?
|]
(enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, currentTs, operatorId)
getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [(Maybe PresetOperator, Maybe ServerOperator)]
getUpdateServerOperators db presetOps newUser = do
conds <- map toUsageConditions <$> DB.query_ db usageCondsQuery
now <- getCurrentTime
let (acceptForSimplex_, currentConds, condsToAdd) = usageConditionsToAdd newUser now conds
mapM_ insertConditions condsToAdd
latestAcceptedConds_ <- getLatestAcceptedConditions db
ops <- updatedServerOperators presetOps <$> getServerOperators_ db
forM ops $ traverse $ mapM $ \(ASO _ op) -> -- traverse for tuple, mapM for Maybe
case operatorId op of
DBNewEntity -> do
op' <- insertOperator op
case (operatorTag op', acceptForSimplex_) of
(Just OTSimplex, Just cond) -> autoAcceptConditions op' cond
_ -> pure op'
DBEntityId _ -> do
updateOperator op
getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case
CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds
CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds
ca -> pure op {conditionsAcceptance = ca}
where
insertConditions UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} =
DB.execute
db
[sql|
INSERT INTO protocol_servers
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
INSERT INTO usage_conditions
(usage_conditions_id, conditions_commit, notified_at, created_at)
VALUES (?,?,?,?)
|]
((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, enabled, userId, currentTs, currentTs))
pure $ Right ()
(conditionsId, conditionsCommit, notifiedAt, createdAt)
updateOperator :: ServerOperator -> IO ()
updateOperator ServerOperator {operatorId, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} =
DB.execute
db
[sql|
UPDATE server_operators
SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?
WHERE server_operator_id = ?
|]
(tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, operatorId)
insertOperator :: NewServerOperator -> IO ServerOperator
insertOperator op@ServerOperator {operatorTag, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} = do
DB.execute
db
[sql|
INSERT INTO server_operators
(server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy)
VALUES (?,?,?,?,?,?,?,?,?)
|]
(operatorTag, tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles)
opId <- insertedRowId db
pure op {operatorId = DBEntityId opId}
autoAcceptConditions op UsageConditions {conditionsCommit} =
acceptConditions_ db op conditionsCommit Nothing
$> op {conditionsAcceptance = CAAccepted Nothing}
serverOperatorQuery :: Query
serverOperatorQuery =
[sql|
SELECT server_operator_id, server_operator_tag, trade_name, legal_name,
server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy
FROM server_operators
|]
getServerOperators_ :: DB.Connection -> IO [ServerOperator]
getServerOperators_ db = map toServerOperator <$> DB.query_ db serverOperatorQuery
toServerOperator :: (DBEntityId, Maybe OperatorTag, Text, Maybe Text, Text, Bool) :. (Bool, Bool) :. (Bool, Bool) -> ServerOperator
toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, enabled) :. smpRoles' :. xftpRoles') =
ServerOperator
{ operatorId,
operatorTag,
tradeName,
legalName,
serverDomains = T.splitOn "," domains,
conditionsAcceptance = CARequired Nothing,
enabled,
smpRoles = serverRoles smpRoles',
xftpRoles = serverRoles xftpRoles'
}
where
protocol = decodeLatin1 $ strEncode $ protocolTypeI @p
serverRoles (storage, proxy) = ServerRoles {storage, proxy}
getOperatorConditions_ :: DB.Connection -> ServerOperator -> UsageConditions -> Maybe UsageConditions -> UTCTime -> IO ConditionsAcceptance
getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {conditionsCommit = currentCommit, createdAt, notifiedAt} latestAcceptedConds_ now = do
case latestAcceptedConds_ of
Nothing -> pure $ CARequired Nothing -- no conditions accepted by any operator
Just UsageConditions {conditionsCommit = latestAcceptedCommit} -> do
operatorAcceptedConds_ <-
maybeFirstRow id $
DB.query
db
[sql|
SELECT conditions_commit, accepted_at
FROM operator_usage_conditions
WHERE server_operator_id = ?
ORDER BY operator_usage_conditions_id DESC
LIMIT 1
|]
(Only operatorId)
pure $ case operatorAcceptedConds_ of
Just (operatorCommit, acceptedAt_)
| operatorCommit /= latestAcceptedCommit -> CARequired Nothing -- TODO should we consider this operator disabled?
| currentCommit /= latestAcceptedCommit -> CARequired $ conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt)
| otherwise -> CAAccepted acceptedAt_
_ -> CARequired Nothing -- no conditions were accepted for this operator
getCurrentUsageConditions :: DB.Connection -> ExceptT StoreError IO UsageConditions
getCurrentUsageConditions db =
ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $
DB.query_ db (usageCondsQuery <> " DESC LIMIT 1")
usageCondsQuery :: Query
usageCondsQuery =
[sql|
SELECT usage_conditions_id, conditions_commit, notified_at, created_at
FROM usage_conditions
ORDER BY usage_conditions_id
|]
toUsageConditions :: (Int64, Text, Maybe UTCTime, UTCTime) -> UsageConditions
toUsageConditions (conditionsId, conditionsCommit, notifiedAt, createdAt) =
UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt}
getLatestAcceptedConditions :: DB.Connection -> IO (Maybe UsageConditions)
getLatestAcceptedConditions db =
maybeFirstRow toUsageConditions $
DB.query_
db
[sql|
SELECT usage_conditions_id, conditions_commit, notified_at, created_at
FROM usage_conditions
WHERE conditions_commit = (
SELECT conditions_commit
FROM operator_usage_conditions
ORDER BY accepted_at DESC
LIMIT 1
)
|]
setConditionsNotified :: DB.Connection -> Int64 -> UTCTime -> IO ()
setConditionsNotified db condId notifiedAt =
DB.execute db "UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ?" (notifiedAt, condId)
acceptConditions :: DB.Connection -> Int64 -> NonEmpty Int64 -> UTCTime -> ExceptT StoreError IO ()
acceptConditions db condId opIds acceptedAt = do
UsageConditions {conditionsCommit} <- getUsageConditionsById_ db condId
operators <- mapM getServerOperator_ opIds
let ts = Just acceptedAt
liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit ts
where
getServerOperator_ opId =
ExceptT $ firstRow toServerOperator (SEOperatorNotFound opId) $
DB.query db (serverOperatorQuery <> " WHERE server_operator_id = ?") (Only opId)
acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> Maybe UTCTime -> IO ()
acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt =
DB.execute
db
[sql|
INSERT INTO operator_usage_conditions
(server_operator_id, server_operator_tag, conditions_commit, accepted_at)
VALUES (?,?,?,?)
|]
(operatorId, operatorTag, conditionsCommit, acceptedAt)
getUsageConditionsById_ :: DB.Connection -> Int64 -> ExceptT StoreError IO UsageConditions
getUsageConditionsById_ db conditionsId =
ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $
DB.query
db
[sql|
SELECT usage_conditions_id, conditions_commit, notified_at, created_at
FROM usage_conditions
WHERE usage_conditions_id = ?
|]
(Only conditionsId)
setUserServers :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> ExceptT StoreError IO UserOperatorServers
setUserServers db user ts = checkConstraint SEUniqueID . liftIO . setUserServers' db user ts
setUserServers' :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> IO UserOperatorServers
setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, smpServers, xftpServers} = do
mapM_ (updateServerOperator db ts) operator
smpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPSMP) smpServers
xftpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPXFTP) xftpServers
pure UserOperatorServers {operator, smpServers = smpSrvs', xftpServers = xftpSrvs'}
where
upsertOrDelete :: ProtocolTypeI p => SProtocolType p -> AUserServer p -> IO (Maybe (UserServer p))
upsertOrDelete p (AUS _ s@UserServer {serverId, deleted}) = case serverId of
DBNewEntity
| deleted -> pure Nothing
| otherwise -> Just <$> insertProtocolServer db p user ts s
DBEntityId srvId
| deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, False)
| otherwise -> Just s <$ updateProtocolServer db p ts s
createCall :: DB.Connection -> User -> Call -> UTCTime -> IO ()
createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do

View file

@ -127,6 +127,8 @@ data StoreError
| SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId}
| SERemoteCtrlDuplicateCA
| SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId}
| SEOperatorNotFound {serverOperatorId :: Int64}
| SEUsageConditionsNotFound
deriving (Show, Exception)
$(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError)

View file

@ -1,6 +1,7 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Terminal where
@ -13,15 +14,15 @@ import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Database.SQLite.Simple (SQLError (..))
import qualified Database.SQLite.Simple as DB
import Simplex.Chat (defaultChatConfig)
import Simplex.Chat (_defaultNtfServers, defaultChatConfig, operatorSimpleXChat)
import Simplex.Chat.Controller
import Simplex.Chat.Core
import Simplex.Chat.Help (chatWelcome)
import Simplex.Chat.Operators
import Simplex.Chat.Options
import Simplex.Chat.Terminal.Input
import Simplex.Chat.Terminal.Output
import Simplex.FileTransfer.Client.Presets (defaultXFTPServers)
import Simplex.Messaging.Agent.Env.SQLite (presetServerCfg)
import Simplex.Messaging.Client (NetworkConfig (..), SMPProxyFallback (..), SMPProxyMode (..), defaultNetworkConfig)
import Simplex.Messaging.Util (raceAny_)
import System.IO (hFlush, hSetEcho, stdin, stdout)
@ -29,20 +30,24 @@ import System.IO (hFlush, hSetEcho, stdin, stdout)
terminalChatConfig :: ChatConfig
terminalChatConfig =
defaultChatConfig
{ defaultServers =
DefaultAgentServers
{ smp =
L.fromList $
map
(presetServerCfg True)
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion",
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion",
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion"
],
useSMP = 3,
ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"],
xftp = L.map (presetServerCfg True) defaultXFTPServers,
useXFTP = L.length defaultXFTPServers,
{ presetServers =
PresetServers
{ operators =
[ PresetOperator
{ operator = Just operatorSimpleXChat,
smp =
map
(presetServer True)
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion",
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion",
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion"
],
useSMP = 3,
xftp = map (presetServer True) $ L.toList defaultXFTPServers,
useXFTP = 3
}
],
ntf = _defaultNtfServers,
netCfg =
defaultNetworkConfig
{ smpProxyMode = SPMUnknown,

View file

@ -10,7 +10,7 @@ import Data.Maybe (fromMaybe)
import Data.Time.Clock (getCurrentTime)
import Data.Time.LocalTime (getCurrentTimeZone)
import Network.Socket
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), DefaultAgentServers (DefaultAgentServers, netCfg), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString)
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString)
import Simplex.Chat.Core
import Simplex.Chat.Options
import Simplex.Chat.Terminal
@ -56,7 +56,7 @@ simplexChatCLI' cfg opts@ChatOpts {chatCmd, chatCmdLog, chatCmdDelay, chatServer
putStrLn $ serializeChatResponse (rh, Just user) ts tz rh r
welcome :: ChatConfig -> ChatOpts -> IO ()
welcome ChatConfig {defaultServers = DefaultAgentServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} =
welcome ChatConfig {presetServers = PresetServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} =
mapM_
putStrLn
[ versionString versionNumber,

View file

@ -25,6 +25,7 @@ import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)
import Data.String
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1)
@ -42,6 +43,7 @@ import Simplex.Chat.Help
import Simplex.Chat.Markdown
import Simplex.Chat.Messages hiding (NewChatItem (..))
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Operators
import Simplex.Chat.Protocol
import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange)
import Simplex.Chat.Remote.Types
@ -53,7 +55,7 @@ import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme
import qualified Simplex.FileTransfer.Transport as XFTP
import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..))
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerCfg (..))
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerRoles (..))
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..), SocksMode (..))
@ -63,7 +65,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..))
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol)
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.Transport.Client (TransportHost (..))
import Simplex.Messaging.Util (safeDecodeUtf8, tshow)
@ -95,8 +97,11 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRChats chats -> viewChats ts tz chats
CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat]
CRApiParsedMarkdown ft -> [viewJSON ft]
CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca
CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp)
CRUserServersValidation {} -> []
CRUsageConditions current _ accepted_ -> viewUsageConditions current accepted_
CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl
CRNetworkConfig cfg -> viewNetworkConfig cfg
CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile
@ -1209,27 +1214,43 @@ viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', sho
"profile is " <> if isJust viewPwdHash then "hidden" else "visible"
]
viewUserServers :: AUserProtoServers -> Bool -> [StyledString]
viewUserServers (AUPS UserProtoServers {serverProtocol = p, protoServers, presetServers}) testView =
customServers
<> if testView
then []
else
[ "",
"use " <> highlight (srvCmd <> " test <srv>") <> " to test " <> pName <> " server connection",
"use " <> highlight (srvCmd <> " <srv1[,srv2,...]>") <> " to configure " <> pName <> " servers",
"use " <> highlight (srvCmd <> " default") <> " to remove configured " <> pName <> " servers and use presets"
]
<> case p of
SPSMP -> ["(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)"]
SPXFTP -> ["(chat option " <> highlight' "-xftp-servers" <> " has precedence over saved XFTP servers for chat session)"]
viewUserServers :: UserOperatorServers -> [StyledString]
viewUserServers (UserOperatorServers _ [] []) = []
viewUserServers UserOperatorServers {operator, smpServers, xftpServers} =
[plain $ maybe "Your servers" shortViewOperator operator]
<> viewServers SPSMP smpServers
<> viewServers SPXFTP xftpServers
where
srvCmd = "/" <> strEncode p
pName = protocolName p
customServers =
if null protoServers
then ("no " <> pName <> " servers saved, using presets: ") : viewServers presetServers
else viewServers protoServers
viewServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [UserServer p] -> [StyledString]
viewServers _ [] = []
viewServers p srvs
| maybe True (\ServerOperator {enabled} -> enabled) operator =
[" " <> protocolName p <> " servers" <> maybe "" ((" " <>) . viewRoles) operator]
<> map (plain . (" " <> ) . viewServer) srvs
| otherwise = []
where
viewServer UserServer {server, preset, tested, enabled} = safeDecodeUtf8 (strEncode server) <> serverInfo
where
serverInfo = if null serverInfo_ then "" else parens $ T.intercalate ", " serverInfo_
serverInfo_ = ["preset" | preset] <> testedInfo <> ["disabled" | not enabled]
testedInfo = maybe [] (\t -> ["test: " <> if t then "passed" else "failed"]) tested
viewRoles op@ServerOperator {enabled}
| not enabled = "disabled"
| storage rs && proxy rs = "enabled"
| storage rs = "enabled storage"
| proxy rs = "enabled proxy"
| otherwise = "disabled (servers known)"
where
rs = operatorRoles p op
serversUserHelp :: [StyledString]
serversUserHelp =
[ "",
"use " <> highlight' "/smp test <srv>" <> " to test SMP server connection",
"use " <> highlight' "/smp <srv1[,srv2,...]>" <> " to configure SMP servers",
"or the same commands starting from /xftp for XFTP servers",
"chat options " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") and " <> highlight' "--xftp-servers" <> " have precedence over preset servers for new user profiles"
]
protocolName :: ProtocolTypeI p => SProtocolType p -> StyledString
protocolName = plain . map toUpper . T.unpack . decodeLatin1 . strEncode
@ -1250,6 +1271,68 @@ viewServerTestResult (AProtoServerWithAuth p _) = \case
where
pName = protocolName p
viewServerOperators :: [ServerOperator] -> Maybe UsageConditionsAction -> [StyledString]
viewServerOperators ops ca = map (plain . viewOperator) ops <> maybe [] viewConditionsAction ca
viewOperator :: ServerOperator' s -> Text
viewOperator op@ServerOperator {tradeName, legalName, serverDomains, conditionsAcceptance} =
viewOpIdTag op
<> tradeName
<> maybe "" parens legalName
<> (", domains: " <> T.intercalate ", " serverDomains)
<> (", servers: " <> viewOpEnabled op)
<> (", conditions: " <> viewOpConditions conditionsAcceptance)
shortViewOperator :: ServerOperator -> Text
shortViewOperator ServerOperator {operatorId = DBEntityId opId, tradeName, enabled} =
tshow opId <> ". " <> tradeName <> parens (if enabled then "enabled" else "disabled")
viewOpIdTag :: ServerOperator' s -> Text
viewOpIdTag ServerOperator {operatorId, operatorTag} = case operatorId of
DBEntityId i -> tshow i <> tag
DBNewEntity -> tag
where
tag = maybe "" (parens . textEncode) operatorTag <> ". "
viewOpConditions :: ConditionsAcceptance -> Text
viewOpConditions = \case
CAAccepted ts -> viewCond "accepted" ts
CARequired ts -> viewCond "required" ts
where
viewCond w ts = w <> maybe "" (parens . tshow) ts
viewOpEnabled :: ServerOperator' s -> Text
viewOpEnabled ServerOperator {enabled, smpRoles, xftpRoles}
| not enabled = "disabled"
| no smpRoles && no xftpRoles = "disabled (servers known)"
| both smpRoles && both xftpRoles = "enabled"
| otherwise = "SMP " <> viewRoles smpRoles <> ", XFTP " <> viewRoles xftpRoles
where
no rs = not $ storage rs || proxy rs
both rs = storage rs && proxy rs
viewRoles rs
| both rs = "enabled"
| storage rs = "enabled storage"
| proxy rs = "enabled proxy"
| otherwise = "disabled (servers known)"
viewConditionsAction :: UsageConditionsAction -> [StyledString]
viewConditionsAction = \case
UCAReview {operators, deadline, showNotice} | showNotice -> case deadline of
Just ts -> [plain $ "The new conditions will be accepted for " <> ops <> " at " <> tshow ts]
Nothing -> [plain $ "The new conditions have to be accepted for " <> ops]
where
ops = T.intercalate ", " $ map legalName_ operators
legalName_ ServerOperator {tradeName, legalName} = fromMaybe tradeName legalName
_ -> []
viewUsageConditions :: UsageConditions -> Maybe UsageConditions -> [StyledString]
viewUsageConditions current accepted_ =
[plain $ "Current conditions: " <> viewConds current <> maybe "" (\ac -> ", accepted conditions: " <> viewConds ac) accepted_]
where
viewConds UsageConditions {conditionsId, conditionsCommit, notifiedAt} =
tshow conditionsId <> maybe "" (const " (notified)") notifiedAt <> ". " <> conditionsCommit
viewChatItemTTL :: Maybe Int64 -> [StyledString]
viewChatItemTTL = \case
Nothing -> ["old messages are not being deleted"]
@ -1326,9 +1409,6 @@ viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} =
["receiving messages via: " <> viewRcvQueuesInfo rcvQueuesInfo | not $ null rcvQueuesInfo]
<> ["sending messages via: " <> viewSndQueuesInfo sndQueuesInfo | not $ null sndQueuesInfo]
viewServers :: ProtocolTypeI p => NonEmpty (ServerCfg p) -> [StyledString]
viewServers = map (plain . B.unpack . strEncode . (\ServerCfg {server} -> server)) . L.toList
viewRcvQueuesInfo :: [RcvQueueInfo] -> StyledString
viewRcvQueuesInfo = plain . intercalate ", " . map showQueueInfo
where
@ -1926,7 +2006,9 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo
then [versionString version, updateStr, "simplexmq: " <> simplexmqVersion <> parens simplexmqCommit]
else [versionString version, updateStr]
where
parens s = " (" <> s <> ")"
parens :: (IsString a, Semigroup a) => a -> a
parens s = " (" <> s <> ")"
viewRemoteHosts :: [RemoteHostInfo] -> [StyledString]
viewRemoteHosts = \case

View file

@ -376,6 +376,16 @@ userName :: TestCC -> IO [Char]
userName (TestCC ChatController {currentUser} _ _ _ _ _) =
maybe "no current user" (\User {localDisplayName} -> T.unpack localDisplayName) <$> readTVarIO currentUser
testChat :: HasCallStack => Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO ()
testChat = testChatCfgOpts testCfg testOpts
testChatCfgOpts :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO ()
testChatCfgOpts cfg opts p test = testChatN cfg opts [p] test_
where
test_ :: HasCallStack => [TestCC] -> IO ()
test_ [tc] = test tc
test_ _ = error "expected 1 chat client"
testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO ()
testChat2 = testChatCfgOpts2 testCfg testOpts
@ -423,11 +433,10 @@ smpServerCfg =
ServerConfig
{ transports = [(serverPort, transport @TLS, False)],
tbqSize = 1,
-- serverTbqSize = 1,
msgQueueQuota = 16,
msgStoreType = AMSType SMSMemory,
maxJournalMsgCount = 1000,
maxJournalStateLines = 1000,
msgQueueQuota = 16,
maxJournalMsgCount = 24,
maxJournalStateLines = 4,
queueIdBytes = 12,
msgIdBytes = 6,
storeLogFile = Nothing,

View file

@ -25,7 +25,7 @@ import Database.SQLite.Simple (Only (..))
import Simplex.Chat.AppSettings (defaultAppSettings)
import qualified Simplex.Chat.AppSettings as AS
import Simplex.Chat.Call
import Simplex.Chat.Controller (ChatConfig (..), DefaultAgentServers (..))
import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..))
import Simplex.Chat.Messages (ChatItemId)
import Simplex.Chat.Options
import Simplex.Chat.Protocol (supportedChatVRange)
@ -85,6 +85,8 @@ chatDirectTests = do
describe "XFTP servers" $ do
it "get and set XFTP servers" testGetSetXFTPServers
it "test XFTP server connection" testTestXFTPServer
describe "operators and usage conditions" $ do
it "get and enable operators, accept conditions" testOperators
describe "async connection handshake" $ do
describe "connect when initiating client goes offline" $ do
it "curr" $ testAsyncInitiatingOffline testCfg testCfg
@ -240,6 +242,7 @@ testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile te
bob <##. "smp agent error: BROKER"
withSmpServer' serverCfg' $ do
alice <## "server connected localhost ()"
threadDelay 250000
bob ##> ("/_connect plan 1 " <> inv)
bob <## "invitation link: ok to connect"
bob ##> ("/_connect 1 " <> inv)
@ -334,8 +337,8 @@ testRetryConnectingClientTimeout tmp = do
{ quotaExceededTimeout = 1,
messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval}
},
defaultServers =
let def@DefaultAgentServers {netCfg} = defaultServers testCfg
presetServers =
let def@PresetServers {netCfg} = presetServers testCfg
in def {netCfg = (netCfg :: NetworkConfig) {tcpTimeout = 10}}
}
opts' =
@ -1139,24 +1142,36 @@ testSendMultiManyBatches =
testGetSetSMPServers :: HasCallStack => FilePath -> IO ()
testGetSetSMPServers =
testChat2 aliceProfile bobProfile $
\alice _ -> do
alice #$> ("/_servers 1 smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001")
testChat aliceProfile $
\alice -> do
alice ##> "/_servers 1"
alice <## "Your servers"
alice <## " SMP servers"
alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"
alice <## " XFTP servers"
alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"
alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok")
alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im")
alice ##> "/smp"
alice <## "Your servers"
alice <## " SMP servers"
alice <## " smp://1234-w==@smp1.example.im"
alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok")
alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im")
-- alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im")
alice ##> "/smp"
alice <## "Your servers"
alice <## " SMP servers"
alice <## " smp://1234-w==:password@smp1.example.im"
alice #$> ("/smp smp://2345-w==@smp2.example.im smp://3456-w==@smp3.example.im:5224", id, "ok")
alice ##> "/smp"
alice <## "smp://2345-w==@smp2.example.im"
alice <## "smp://3456-w==@smp3.example.im:5224"
alice #$> ("/smp default", id, "ok")
alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001")
alice <## "Your servers"
alice <## " SMP servers"
alice <## " smp://2345-w==@smp2.example.im"
alice <## " smp://3456-w==@smp3.example.im:5224"
testTestSMPServerConnection :: HasCallStack => FilePath -> IO ()
testTestSMPServerConnection =
testChat2 aliceProfile bobProfile $
\alice _ -> do
testChat aliceProfile $
\alice -> do
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"
alice <## "SMP server test passed"
-- to test with password:
@ -1170,24 +1185,35 @@ testTestSMPServerConnection =
testGetSetXFTPServers :: HasCallStack => FilePath -> IO ()
testGetSetXFTPServers =
testChat2 aliceProfile bobProfile $
\alice _ -> withXFTPServer $ do
alice #$> ("/_servers 1 xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002")
testChat aliceProfile $
\alice -> withXFTPServer $ do
alice ##> "/_servers 1"
alice <## "Your servers"
alice <## " SMP servers"
alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"
alice <## " XFTP servers"
alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"
alice #$> ("/xftp xftp://1234-w==@xftp1.example.im", id, "ok")
alice #$> ("/xftp", id, "xftp://1234-w==@xftp1.example.im")
alice ##> "/xftp"
alice <## "Your servers"
alice <## " XFTP servers"
alice <## " xftp://1234-w==@xftp1.example.im"
alice #$> ("/xftp xftp://1234-w==:password@xftp1.example.im", id, "ok")
alice #$> ("/xftp", id, "xftp://1234-w==:password@xftp1.example.im")
alice ##> "/xftp"
alice <## "Your servers"
alice <## " XFTP servers"
alice <## " xftp://1234-w==:password@xftp1.example.im"
alice #$> ("/xftp xftp://2345-w==@xftp2.example.im xftp://3456-w==@xftp3.example.im:5224", id, "ok")
alice ##> "/xftp"
alice <## "xftp://2345-w==@xftp2.example.im"
alice <## "xftp://3456-w==@xftp3.example.im:5224"
alice #$> ("/xftp default", id, "ok")
alice #$> ("/xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002")
alice <## "Your servers"
alice <## " XFTP servers"
alice <## " xftp://2345-w==@xftp2.example.im"
alice <## " xftp://3456-w==@xftp3.example.im:5224"
testTestXFTPServer :: HasCallStack => FilePath -> IO ()
testTestXFTPServer =
testChat2 aliceProfile bobProfile $
\alice _ -> withXFTPServer $ do
testChat aliceProfile $
\alice -> withXFTPServer $ do
alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7002"
alice <## "XFTP server test passed"
-- to test with password:
@ -1199,6 +1225,36 @@ testTestXFTPServer =
alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK}"
alice <## "Possibly, certificate fingerprint in XFTP server address is incorrect"
testOperators :: HasCallStack => FilePath -> IO ()
testOperators =
testChatCfgOpts testCfg opts' aliceProfile $
\alice -> do
-- initial load
alice ##> "/_conditions"
alice <##. "Current conditions: 2."
alice ##> "/_operators"
alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required ("
alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required"
alice <##. "The new conditions will be accepted for SimpleX Chat Ltd at "
-- set conditions notified
alice ##> "/_conditions_notified 2"
alice <## "ok"
alice ##> "/_operators"
alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required ("
alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required"
alice ##> "/_conditions"
alice <##. "Current conditions: 2 (notified)."
-- accept conditions
alice ##> "/_accept_conditions 2 1,2"
alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: accepted ("
alice <##. "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: accepted ("
-- update operators
alice ##> "/operators 2:on:smp=proxy"
alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: accepted ("
alice <##. "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: SMP enabled proxy, XFTP enabled, conditions: accepted ("
where
opts' = testOpts {coreOptions = testCoreOpts {smpServers = [], xftpServers = []}}
testAsyncInitiatingOffline :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO ()
testAsyncInitiatingOffline aliceCfg bobCfg tmp = do
inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do
@ -1800,11 +1856,15 @@ testCreateUserSameServers =
where
checkCustomServers alice = do
alice ##> "/smp"
alice <## "smp://2345-w==@smp2.example.im"
alice <## "smp://3456-w==@smp3.example.im:5224"
alice <## "Your servers"
alice <## " SMP servers"
alice <## " smp://2345-w==@smp2.example.im"
alice <## " smp://3456-w==@smp3.example.im:5224"
alice ##> "/xftp"
alice <## "xftp://2345-w==@xftp2.example.im"
alice <## "xftp://3456-w==@xftp3.example.im:5224"
alice <## "Your servers"
alice <## " XFTP servers"
alice <## " xftp://2345-w==@xftp2.example.im"
alice <## " xftp://3456-w==@xftp3.example.im:5224"
testDeleteUser :: HasCallStack => FilePath -> IO ()
testDeleteUser =

View file

@ -1,8 +1,10 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PostfixOperators #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module ChatTests.Groups where
@ -1986,7 +1988,6 @@ testGroupAsync tmp = do
(bob <## "#team: you joined the group")
alice #> "#team hello bob"
bob <# "#team alice> hello bob"
print (1 :: Integer)
withTestChat tmp "alice" $ \alice -> do
withNewTestChat tmp "cath" cathProfile $ \cath -> do
alice <## "1 contacts connected (use /cs for the list)"
@ -2006,7 +2007,6 @@ testGroupAsync tmp = do
]
alice #> "#team hello cath"
cath <# "#team alice> hello cath"
print (2 :: Integer)
withTestChat tmp "bob" $ \bob -> do
withTestChat tmp "cath" $ \cath -> do
concurrentlyN_
@ -2022,7 +2022,6 @@ testGroupAsync tmp = do
cath <## "#team: member bob (Bob) is connected"
]
threadDelay 500000
print (3 :: Integer)
withTestChat tmp "bob" $ \bob -> do
withNewTestChat tmp "dan" danProfile $ \dan -> do
bob <## "2 contacts connected (use /cs for the list)"
@ -2042,7 +2041,6 @@ testGroupAsync tmp = do
]
threadDelay 1000000
threadDelay 1000000
print (4 :: Integer)
withTestChat tmp "alice" $ \alice -> do
withTestChat tmp "cath" $ \cath -> do
withTestChat tmp "dan" $ \dan -> do
@ -2064,7 +2062,6 @@ testGroupAsync tmp = do
dan <## "#team: member cath (Catherine) is connected"
]
threadDelay 1000000
print (5 :: Integer)
withTestChat tmp "alice" $ \alice -> do
withTestChat tmp "bob" $ \bob -> do
withTestChat tmp "cath" $ \cath -> do

View file

@ -2,6 +2,7 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PostfixOperators #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module ChatTests.Profiles where
@ -272,6 +273,7 @@ testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile
bob <##. "smp agent error: BROKER"
withSmpServer' serverCfg' $ do
alice <## "server connected localhost ()"
threadDelay 250000
bob ##> ("/_connect plan 1 " <> cLink)
bob <## "contact address: ok to connect"
bob ##> ("/_connect 1 " <> cLink)
@ -1733,7 +1735,15 @@ testChangePCCUserDiffSrv tmp = do
-- Create new user with different servers
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice #$> ("/smp smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003", id, "ok")
alice ##> "/smp"
alice <## "Your servers"
alice <## " SMP servers"
alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"
alice #$> ("/smp smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003", id, "ok")
alice ##> "/smp"
alice <## "Your servers"
alice <## " SMP servers"
alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003"
alice ##> "/user alice"
showActiveUser alice "alice (Alice)"
-- Change connection to newly created user and use the newly created connection

132
tests/OperatorTests.hs Normal file
View file

@ -0,0 +1,132 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -Wno-orphans #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module OperatorTests (operatorTests) where
import Data.Bifunctor (second)
import qualified Data.List.NonEmpty as L
import Simplex.Chat
import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..))
import Simplex.Chat.Operators
import Simplex.Chat.Types
import Simplex.FileTransfer.Client.Presets (defaultXFTPServers)
import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles)
import Simplex.Messaging.Protocol
import Test.Hspec
operatorTests :: Spec
operatorTests = describe "managing server operators" $ do
validateServersTest
updatedServersTest
validateServersTest :: Spec
validateServersTest = describe "validate user servers" $ do
it "should pass valid user servers" $ validateUserServers [valid] [] `shouldBe` []
it "should fail without servers" $ do
validateUserServers [invalidNoServers] [] `shouldBe` [USENoServers aSMP Nothing]
validateUserServers [invalidDisabled] [] `shouldBe` [USENoServers aSMP Nothing]
validateUserServers [invalidDisabledOp] [] `shouldBe` [USENoServers aSMP Nothing, USENoServers aXFTP Nothing]
it "should fail without servers with storage role" $ do
validateUserServers [invalidNoStorage] [] `shouldBe` [USEStorageMissing aSMP Nothing]
it "should fail with duplicate host" $ do
validateUserServers [invalidDuplicate] [] `shouldBe`
[ USEDuplicateServer aSMP "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion" "smp8.simplex.im",
USEDuplicateServer aSMP "smp://abcd@smp8.simplex.im" "smp8.simplex.im"
]
where
aSMP = AProtocolType SPSMP
aXFTP = AProtocolType SPXFTP
updatedServersTest :: Spec
updatedServersTest = describe "validate user servers" $ do
it "adding preset operators on first start" $ do
let ops' :: [(Maybe PresetOperator, Maybe AServerOperator)] =
updatedServerOperators operators []
length ops' `shouldBe` 2
all addedPreset ops' `shouldBe` True
let ops'' :: [(Maybe PresetOperator, Maybe ServerOperator)] =
saveOps ops' -- mock getUpdateServerOperators
uss <- groupByOperator' (ops'', [], []) -- no stored servers
length uss `shouldBe` 3
[op1, op2, op3] <- pure $ map updatedUserServers uss
[p1, p2] <- pure operators -- presets
sameServers p1 op1
sameServers p2 op2
null (servers' SPSMP op3) `shouldBe` True
null (servers' SPXFTP op3) `shouldBe` True
it "adding preset operators and assiging servers to operator for existing users" $ do
let ops' = updatedServerOperators operators []
ops'' = saveOps ops'
uss <-
groupByOperator'
( ops'',
saveSrvs $ take 3 simplexChatSMPServers <> [newUserServer "smp://abcd@smp.example.im"],
saveSrvs $ map (presetServer True) $ L.take 3 defaultXFTPServers
)
[op1, op2, op3] <- pure $ map updatedUserServers uss
[p1, p2] <- pure operators -- presets
sameServers p1 op1
sameServers p2 op2
map srvHost' (servers' SPSMP op3) `shouldBe` [["smp.example.im"]]
null (servers' SPXFTP op3) `shouldBe` True
where
addedPreset = \case
(Just PresetOperator {operator = Just op}, Just (ASO SDBNew op')) -> operatorTag op == operatorTag op'
_ -> False
saveOps = zipWith (\i -> second ((\(ASO _ op) -> op {operatorId = DBEntityId i}) <$>)) [1..]
saveSrvs = zipWith (\i srv -> srv {serverId = DBEntityId i}) [1..]
sameServers preset op = do
map srvHost (pServers SPSMP preset) `shouldBe` map srvHost' (servers' SPSMP op)
map srvHost (pServers SPXFTP preset) `shouldBe` map srvHost' (servers' SPXFTP op)
srvHost' (AUS _ s) = srvHost s
PresetServers {operators} = presetServers defaultChatConfig
deriving instance Eq User
deriving instance Eq UserServersError
valid :: UpdatedUserOperatorServers
valid =
UpdatedUserOperatorServers
{ operator = Just operatorSimpleXChat {operatorId = DBEntityId 1},
smpServers = map (AUS SDBNew) simplexChatSMPServers,
xftpServers = map (AUS SDBNew . presetServer True) $ L.toList defaultXFTPServers
}
invalidNoServers :: UpdatedUserOperatorServers
invalidNoServers = (valid :: UpdatedUserOperatorServers) {smpServers = []}
invalidDisabled :: UpdatedUserOperatorServers
invalidDisabled =
(valid :: UpdatedUserOperatorServers)
{ smpServers = map (AUS SDBNew . (\srv -> (srv :: NewUserServer 'PSMP) {enabled = False})) simplexChatSMPServers
}
invalidDisabledOp :: UpdatedUserOperatorServers
invalidDisabledOp =
(valid :: UpdatedUserOperatorServers)
{ operator = Just operatorSimpleXChat {operatorId = DBEntityId 1, enabled = False}
}
invalidNoStorage :: UpdatedUserOperatorServers
invalidNoStorage =
(valid :: UpdatedUserOperatorServers)
{ operator = Just operatorSimpleXChat {operatorId = DBEntityId 1, smpRoles = allRoles {storage = False}}
}
invalidDuplicate :: UpdatedUserOperatorServers
invalidDuplicate =
(valid :: UpdatedUserOperatorServers)
{ smpServers = map (AUS SDBNew) $ simplexChatSMPServers <> [presetServer True "smp://abcd@smp8.simplex.im"]
}

View file

@ -1,51 +1,66 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeSynonymInstances #-}
{-# OPTIONS_GHC -Wno-orphans #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module RandomServers where
import Control.Monad (replicateM)
import qualified Data.List.NonEmpty as L
import Simplex.Chat (cfgServers, cfgServersToUse, defaultChatConfig, randomServers)
import Simplex.Chat.Controller (ChatConfig (..))
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..))
import Data.Foldable (foldMap')
import Data.List (sortOn)
import Data.List.NonEmpty (NonEmpty)
import Data.Monoid (Sum (..))
import Simplex.Chat (defaultChatConfig, chooseRandomServers)
import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..))
import Simplex.Chat.Operators
import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..))
import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), SProtocolType (..), UserProtocol)
import Test.Hspec
randomServersTests :: Spec
randomServersTests = describe "choosig random servers" $ do
it "should choose 4 random SMP servers and keep the rest disabled" testRandomSMPServers
it "should keep all 6 XFTP servers" testRandomXFTPServers
it "should choose 4 + 3 random SMP servers and keep the rest disabled" testRandomSMPServers
it "should choose 3 + 3 random XFTP servers and keep the rest disabled" testRandomXFTPServers
deriving instance Eq (ServerCfg p)
deriving instance Eq ServerRoles
deriving instance Eq (UserServer' s p)
testRandomSMPServers :: IO ()
testRandomSMPServers = do
[srvs1, srvs2, srvs3] <-
replicateM 3 $
checkEnabled SPSMP 4 False =<< randomServers SPSMP defaultChatConfig
checkEnabled SPSMP 7 False =<< chooseRandomServers (presetServers defaultChatConfig)
(srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` False -- && to avoid rare failures
testRandomXFTPServers :: IO ()
testRandomXFTPServers = do
[srvs1, srvs2, srvs3] <-
replicateM 3 $
checkEnabled SPXFTP 6 True =<< randomServers SPXFTP defaultChatConfig
(srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` True
checkEnabled SPXFTP 6 False =<< chooseRandomServers (presetServers defaultChatConfig)
(srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` False -- && to avoid rare failures
checkEnabled :: UserProtocol p => SProtocolType p -> Int -> Bool -> (L.NonEmpty (ServerCfg p), [ServerCfg p]) -> IO [ServerCfg p]
checkEnabled p n allUsed (srvs, _) = do
let def = defaultServers defaultChatConfig
cfgSrvs = L.sortWith server' $ cfgServers p def
toUse = cfgServersToUse p def
srvs == cfgSrvs `shouldBe` allUsed
L.map enable srvs `shouldBe` L.map enable cfgSrvs
let enbldSrvs = L.filter (\ServerCfg {enabled} -> enabled) srvs
checkEnabled :: UserProtocol p => SProtocolType p -> Int -> Bool -> NonEmpty (PresetOperator) -> IO [NewUserServer p]
checkEnabled p n allUsed presetOps' = do
let PresetServers {operators = presetOps} = presetServers defaultChatConfig
presetSrvs = sortOn server' $ concatMap (pServers p) presetOps
srvs' = sortOn server' $ concatMap (pServers p) presetOps'
Sum toUse = foldMap' (Sum . operatorServersToUse p) presetOps
Sum toUse' = foldMap' (Sum . operatorServersToUse p) presetOps'
length presetOps `shouldBe` length presetOps'
toUse `shouldBe` toUse'
srvs' == presetSrvs `shouldBe` allUsed
map enable srvs' `shouldBe` map enable presetSrvs
let enbldSrvs = filter (\UserServer {enabled} -> enabled) srvs'
toUse `shouldBe` n
length enbldSrvs `shouldBe` n
pure enbldSrvs
where
server' ServerCfg {server = ProtoServerWithAuth srv _} = srv
enable :: forall p. ServerCfg p -> ServerCfg p
enable srv = (srv :: ServerCfg p) {enabled = False}
server' UserServer {server = ProtoServerWithAuth srv _} = srv
enable :: forall p. NewUserServer p -> NewUserServer p
enable srv = (srv :: NewUserServer p) {enabled = False}

View file

@ -10,6 +10,7 @@ import MarkdownTests
import MessageBatching
import MobileTests
import ProtocolTests
import OperatorTests
import RandomServers
import RemoteTests
import SchemaDump
@ -31,6 +32,7 @@ main = do
around tmpBracket $ describe "WebRTC encryption" webRTCTests
describe "Valid names" validNameTests
describe "Message batching" batchingTests
describe "Operators" operatorTests
describe "Random servers" randomServersTests
around testBracket $ do
describe "Mobile API Tests" mobileTests