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 Intents
import SimpleXChat 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 { struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared @ObservedObject var alertManager = AlertManager.shared
@ -30,7 +42,8 @@ struct ContentView: View {
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = 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 showChooseLAMode = false
@State private var showSetPasscode = false @State private var showSetPasscode = false
@State private var waitingForOrPassedAuth = true @State private var waitingForOrPassedAuth = true
@ -261,8 +274,15 @@ struct ContentView: View {
alertManager.showAlert(laNoticeAlert()) alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil { } else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if !showWhatsNew { if !noticesShown {
showWhatsNew = shouldShowWhatsNew() 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() connectViaUrl()
} }
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.sheet(isPresented: $showWhatsNew) { .sheet(item: $noticesSheetItem) { item in
WhatsNewView() 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 { if chatModel.setDeliveryReceipts {
SetDeliveryReceiptsView() SetDeliveryReceiptsView()
@ -283,6 +317,15 @@ struct ContentView: View {
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) .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) { private func processUserActivity(_ activity: NSUserActivity) {
let intent = activity.interaction?.intent let intent = activity.interaction?.intent
if let intent = intent as? INStartCallIntent { if let intent = intent as? INStartCallIntent {

View file

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

View file

@ -500,18 +500,6 @@ func apiDeleteToken(token: DeviceToken) async throws {
try await sendCommandOkResp(.apiDeleteToken(token: token)) 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> { func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> {
let userId = try currentUserId("testProtoServer") let userId = try currentUserId("testProtoServer")
let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server)) let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
@ -524,6 +512,65 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail
throw r 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 { func getChatItemTTL() throws -> ChatItemTTL {
let userId = try currentUserId("getChatItemTTL") let userId = try currentUserId("getChatItemTTL")
return try chatItemTTLResponse(chatSendCmdSync(.apiGetChatItemTTL(userId: userId))) 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()) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
m.chatInitialized = true m.chatInitialized = true
m.currentUser = try apiGetActiveUser() m.currentUser = try apiGetActiveUser()
m.conditions = try getServerOperators()
if m.currentUser == nil { if m.currentUser == nil {
onboardingStageDefault.set(.step1_SimpleXInfo) onboardingStageDefault.set(.step1_SimpleXInfo)
privacyDeliveryReceiptsSet.set(true) 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") logger.debug("startChat")
let m = ChatModel.shared let m = ChatModel.shared
try setNetworkConfig(getNetCfg()) try setNetworkConfig(getNetCfg())
@ -1621,13 +1669,15 @@ func startChat(refreshInvitations: Bool = true) throws {
if let token = m.deviceToken { if let token = m.deviceToken {
registerToken(token: token) registerToken(token: token)
} }
withAnimation { if !onboarding {
let savedOnboardingStage = onboardingStageDefault.get() withAnimation {
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1 let savedOnboardingStage = onboardingStageDefault.get()
? .step3_CreateSimpleXAddress m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
: savedOnboardingStage ? .step3_ChooseServerOperators
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() { : savedOnboardingStage
m.setDeliveryReceipts = true 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 { struct UserPickerSheetView: View {
let sheet: UserPickerSheet let sheet: UserPickerSheet
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@StateObject private var ss = SaveableSettings()
@State private var loaded = false @State private var loaded = false
var body: some View { var body: some View {
@ -76,6 +88,21 @@ struct UserPickerSheetView: View {
{ loaded = true } { 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(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @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_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 @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View { var body: some View {
@ -276,12 +304,6 @@ struct ChatListView: View {
.padding(.top, oneHandUI ? 8 : 0) .padding(.top, oneHandUI ? 8 : 0)
.id("searchBar") .id("searchBar")
} }
if !oneHandUICardShown {
OneHandUICard()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat) ChatListNavLink(chat: chat)
@ -307,6 +329,20 @@ struct ChatListView: View {
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id)) .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) .listStyle(.plain)
.onChange(of: chatModel.chatId) { currentChatId in .onChange(of: chatModel.chatId) { currentChatId in

View file

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

View file

@ -491,15 +491,6 @@ struct SMPServerSummaryView: View {
Section("Server address") { Section("Server address") {
Text(summary.smpServer) Text(summary.smpServer)
.textSelection(.enabled) .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 { if let stats = summary.stats {
@ -679,15 +670,6 @@ struct XFTPServerSummaryView: View {
Section("Server address") { Section("Server address") {
Text(summary.xftpServer) Text(summary.xftpServer)
.textSelection(.enabled) .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 { 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) TextField("Enter your name…", text: $displayName)
.focused($focusDisplayName) .focused($focusDisplayName)
Button { Button {
createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss) createProfile()
} label: { } label: {
Label("Create profile", systemImage: "checkmark") 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 { struct CreateFirstProfile: View {
@ -86,40 +115,47 @@ struct CreateFirstProfile: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State private var displayName: String = "" @State private var displayName: String = ""
@FocusState private var focusDisplayName @FocusState private var focusDisplayName
@State private var nextStepNavLinkActive = false
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 20) {
Group { Text("Your profile, contacts and delivered messages are stored on your device.")
Text("Create your profile") .font(.callout)
.font(.largeTitle) .foregroundColor(theme.colors.secondary)
.bold() Text("The profile is only shared with your contacts.")
Text("Your profile, contacts and delivered messages are stored on your device.") .font(.callout)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
Text("The profile is only shared with your contacts.")
.foregroundColor(theme.colors.secondary)
.padding(.bottom)
}
.padding(.bottom)
ZStack(alignment: .topLeading) { HStack {
let name = displayName.trimmingCharacters(in: .whitespaces) let name = displayName.trimmingCharacters(in: .whitespaces)
let validName = mkValidName(name) let validName = mkValidName(name)
if name != validName { ZStack {
Button { if name != validName {
showAlert(.invalidNameError(validName: validName)) Button {
} label: { showAlert(.invalidNameError(validName: validName))
Image(systemName: "exclamationmark.circle").foregroundColor(.red) } 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) TextField("Enter your name…", text: $displayName)
.focused($focusDisplayName) .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() Spacer()
onboardingButtons()
createProfileButton()
.padding(.bottom)
} }
.onAppear() { .onAppear() {
focusDisplayName = true focusDisplayName = true
@ -129,88 +165,85 @@ struct CreateFirstProfile: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
func onboardingButtons() -> some View { func createProfileButton() -> some View {
HStack { ZStack {
Button { Button {
hideKeyboard() createProfile()
withAnimation {
m.onboardingStage = .step1_SimpleXInfo
}
} label: { } label: {
HStack { Text("Create profile")
Image(systemName: "lessthan")
Text("About SimpleX")
}
}
Spacer()
Button {
createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
} label: {
HStack {
Text("Create")
Image(systemName: "greaterthan")
}
} }
.buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName)))
.disabled(!canCreateProfile(displayName)) .disabled(!canCreateProfile(displayName))
NavigationLink(isActive: $nextStepNavLinkActive) {
nextStepDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
} }
} }
private func showAlert(_ alert: UserProfileAlert) { private func showAlert(_ alert: UserProfileAlert) {
AlertManager.shared.showAlert(userProfileAlert(alert, $displayName)) 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) { private func showCreateProfileAlert(
hideKeyboard() showAlert: (UserProfileAlert) -> Void,
let profile = Profile( _ error: Error
displayName: displayName.trimmingCharacters(in: .whitespaces), ) {
fullName: ""
)
let m = ChatModel.shared let m = ChatModel.shared
do { switch error as? ChatResponse {
AppChatState.shared.set(.active) case .chatCmdError(_, .errorStore(.duplicateName)),
m.currentUser = try apiCreateActiveUser(profile) .chatCmdError(_, .error(.userExists)):
// .isEmpty check is redundant here, but it makes it clearer what is going on if m.currentUser == nil {
if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) { AlertManager.shared.showAlert(duplicateUserAlert)
try startChat()
withAnimation {
onboardingStageDefault.set(.step3_CreateSimpleXAddress)
m.onboardingStage = .step3_CreateSimpleXAddress
}
} else { } else {
onboardingStageDefault.set(.onboardingComplete) showAlert(.duplicateUserError)
m.onboardingStage = .onboardingComplete
dismiss()
m.users = try listUsers()
try getUserChatData()
} }
} catch let error { case .chatCmdError(_, .error(.invalidDisplayName)):
switch error as? ChatResponse { if m.currentUser == nil {
case .chatCmdError(_, .errorStore(.duplicateName)), AlertManager.shared.showAlert(invalidDisplayNameAlert)
.chatCmdError(_, .error(.userExists)): } else {
if m.currentUser == nil { showAlert(.invalidDisplayNameError)
AlertManager.shared.showAlert(duplicateUserAlert) }
} else { default:
showAlert(.duplicateUserError) let err: LocalizedStringKey = "Error: \(responseError(error))"
} if m.currentUser == nil {
case .chatCmdError(_, .error(.invalidDisplayName)): AlertManager.shared.showAlert(creatUserErrorAlert(err))
if m.currentUser == nil { } else {
AlertManager.shared.showAlert(invalidDisplayNameAlert) showAlert(.createUserError(error: err))
} 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 { private func canCreateProfile(_ displayName: String) -> Bool {

View file

@ -9,13 +9,16 @@
import SwiftUI import SwiftUI
struct HowItWorks: View { struct HowItWorks: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
var onboarding: Bool var onboarding: Bool
@Binding var createProfileNavLinkActive: Bool
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("How SimpleX works") Text("How SimpleX works")
.font(.largeTitle) .font(.largeTitle)
.bold()
.padding(.vertical) .padding(.vertical)
ScrollView { ScrollView {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@ -37,8 +40,8 @@ struct HowItWorks: View {
Spacer() Spacer()
if onboarding { if onboarding {
OnboardingActionButton() createFirstProfileButton()
.padding(.bottom, 8) .padding(.bottom)
} }
} }
.lineLimit(10) .lineLimit(10)
@ -46,10 +49,23 @@ struct HowItWorks: View {
.frame(maxHeight: .infinity, alignment: .top) .frame(maxHeight: .infinity, alignment: .top)
.modifier(ThemedBackground()) .modifier(ThemedBackground())
} }
private func createFirstProfileButton() -> some View {
Button {
dismiss()
createProfileNavLinkActive = true
} label: {
Text("Create your profile")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
} }
struct HowItWorks_Previews: PreviewProvider { struct HowItWorks_Previews: PreviewProvider {
static var previews: some View { 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 onboarding: OnboardingStage
var body: some View { var body: some View {
switch onboarding { NavigationView {
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) switch onboarding {
case .step2_CreateProfile: CreateFirstProfile() case .step1_SimpleXInfo:
case .step3_CreateSimpleXAddress: CreateSimpleXAddress() SimpleXInfo(onboarding: true)
case .step4_SetNotificationsMode: SetNotificationsMode() .modifier(ThemedBackground())
case .onboardingComplete: EmptyView() 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 { enum OnboardingStage: String, Identifiable {
case step1_SimpleXInfo case step1_SimpleXInfo
case step2_CreateProfile case step2_CreateProfile // deprecated
case step3_CreateSimpleXAddress case step3_CreateSimpleXAddress // deprecated
case step3_ChooseServerOperators
case step4_SetNotificationsMode case step4_SetNotificationsMode
case onboardingComplete case onboardingComplete

View file

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

View file

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

View file

@ -7,190 +7,209 @@
// //
import SwiftUI import SwiftUI
import SimpleXChat
private struct VersionDescription { private struct VersionDescription {
var version: String var version: String
var post: URL? var post: URL?
var features: [FeatureDescription] var features: [Feature]
} }
private struct FeatureDescription { private enum Feature: Identifiable {
var icon: String? case feature(Description)
var title: LocalizedStringKey case view(FeatureView)
var description: LocalizedStringKey?
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)] = [] var subfeatures: [(icon: String, description: LocalizedStringKey)] = []
} }
private struct FeatureView {
let icon: String?
let title: LocalizedStringKey
let view: () -> any View
}
private let versionDescriptions: [VersionDescription] = [ private let versionDescriptions: [VersionDescription] = [
VersionDescription( VersionDescription(
version: "v4.2", version: "v4.2",
post: URL(string: "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"), post: URL(string: "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "checkmark.shield", icon: "checkmark.shield",
title: "Security assessment", title: "Security assessment",
description: "SimpleX Chat security was audited by Trail of Bits." description: "SimpleX Chat security was audited by Trail of Bits."
), )),
FeatureDescription( .feature(Description(
icon: "person.2", icon: "person.2",
title: "Group links", title: "Group links",
description: "Admins can create the links to join groups." description: "Admins can create the links to join groups."
), )),
FeatureDescription( .feature(Description(
icon: "checkmark", icon: "checkmark",
title: "Auto-accept contact requests", title: "Auto-accept contact requests",
description: "With optional welcome message." description: "With optional welcome message."
), )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v4.3", version: "v4.3",
post: URL(string: "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html"), post: URL(string: "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "mic", icon: "mic",
title: "Voice messages", title: "Voice messages",
description: "Max 30 seconds, received instantly." description: "Max 30 seconds, received instantly."
), )),
FeatureDescription( .feature(Description(
icon: "trash.slash", icon: "trash.slash",
title: "Irreversible message deletion", title: "Irreversible message deletion",
description: "Your contacts can allow full message deletion." description: "Your contacts can allow full message deletion."
), )),
FeatureDescription( .feature(Description(
icon: "externaldrive.connected.to.line.below", icon: "externaldrive.connected.to.line.below",
title: "Improved server configuration", title: "Improved server configuration",
description: "Add servers by scanning QR codes." description: "Add servers by scanning QR codes."
), )),
FeatureDescription( .feature(Description(
icon: "eye.slash", icon: "eye.slash",
title: "Improved privacy and security", title: "Improved privacy and security",
description: "Hide app screen in the recent apps." description: "Hide app screen in the recent apps."
), )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v4.4", version: "v4.4",
post: URL(string: "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html"), post: URL(string: "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "stopwatch", icon: "stopwatch",
title: "Disappearing messages", title: "Disappearing messages",
description: "Sent messages will be deleted after set time." description: "Sent messages will be deleted after set time."
), )),
FeatureDescription( .feature(Description(
icon: "ellipsis.circle", icon: "ellipsis.circle",
title: "Live messages", title: "Live messages",
description: "Recipients see updates as you type them." description: "Recipients see updates as you type them."
), )),
FeatureDescription( .feature(Description(
icon: "checkmark.shield", icon: "checkmark.shield",
title: "Verify connection security", title: "Verify connection security",
description: "Compare security codes with your contacts." description: "Compare security codes with your contacts."
), )),
FeatureDescription( .feature(Description(
icon: "camera", icon: "camera",
title: "GIFs and stickers", title: "GIFs and stickers",
description: "Send them from gallery or custom keyboards." description: "Send them from gallery or custom keyboards."
), )),
FeatureDescription( .feature(Description(
icon: "character", icon: "character",
title: "French interface", title: "French interface",
description: "Thanks to the users contribute via Weblate!" description: "Thanks to the users contribute via Weblate!"
) )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v4.5", version: "v4.5",
post: URL(string: "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html"), post: URL(string: "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "person.crop.rectangle.stack", icon: "person.crop.rectangle.stack",
title: "Multiple chat profiles", title: "Multiple chat profiles",
description: "Different names, avatars and transport isolation." description: "Different names, avatars and transport isolation."
), )),
FeatureDescription( .feature(Description(
icon: "rectangle.and.pencil.and.ellipsis", icon: "rectangle.and.pencil.and.ellipsis",
title: "Message draft", title: "Message draft",
description: "Preserve the last message draft, with attachments." description: "Preserve the last message draft, with attachments."
), )),
FeatureDescription( .feature(Description(
icon: "network.badge.shield.half.filled", icon: "network.badge.shield.half.filled",
title: "Transport isolation", 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)." 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", icon: "lock.doc",
title: "Private filenames", title: "Private filenames",
description: "To protect timezone, image/voice files use UTC." description: "To protect timezone, image/voice files use UTC."
), )),
FeatureDescription( .feature(Description(
icon: "battery.25", icon: "battery.25",
title: "Reduced battery usage", title: "Reduced battery usage",
description: "More improvements are coming soon!" description: "More improvements are coming soon!"
), )),
FeatureDescription( .feature(Description(
icon: "character", icon: "character",
title: "Italian interface", title: "Italian interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
) )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v4.6", version: "v4.6",
post: URL(string: "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html"), post: URL(string: "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "lock", icon: "lock",
title: "Hidden chat profiles", title: "Hidden chat profiles",
description: "Protect your chat profiles with a password!" description: "Protect your chat profiles with a password!"
), )),
FeatureDescription( .feature(Description(
icon: "phone.arrow.up.right", icon: "phone.arrow.up.right",
title: "Audio and video calls", title: "Audio and video calls",
description: "Fully re-implemented - work in background!" description: "Fully re-implemented - work in background!"
), )),
FeatureDescription( .feature(Description(
icon: "flag", icon: "flag",
title: "Group moderation", title: "Group moderation",
description: "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" description: "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)"
), )),
FeatureDescription( .feature(Description(
icon: "plus.message", icon: "plus.message",
title: "Group welcome message", title: "Group welcome message",
description: "Set the message shown to new members!" description: "Set the message shown to new members!"
), )),
FeatureDescription( .feature(Description(
icon: "battery.50", icon: "battery.50",
title: "Further reduced battery usage", title: "Further reduced battery usage",
description: "More improvements are coming soon!" description: "More improvements are coming soon!"
), )),
FeatureDescription( .feature(Description(
icon: "character", icon: "character",
title: "Chinese and Spanish interface", 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)!" description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
), )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v5.0", version: "v5.0",
post: URL(string: "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html"), post: URL(string: "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "arrow.up.doc", icon: "arrow.up.doc",
title: "Videos and files up to 1gb", title: "Videos and files up to 1gb",
description: "Fast and no wait until the sender is online!" description: "Fast and no wait until the sender is online!"
), )),
FeatureDescription( .feature(Description(
icon: "lock", icon: "lock",
title: "App passcode", title: "App passcode",
description: "Set it instead of system authentication." description: "Set it instead of system authentication."
), )),
FeatureDescription( .feature(Description(
icon: "character", icon: "character",
title: "Polish interface", title: "Polish interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
), )),
] ]
), ),
// Also // Also
@ -200,240 +219,240 @@ private let versionDescriptions: [VersionDescription] = [
version: "v5.1", version: "v5.1",
post: URL(string: "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html"), post: URL(string: "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "face.smiling", icon: "face.smiling",
title: "Message reactions", title: "Message reactions",
description: "Finally, we have them! 🚀" description: "Finally, we have them! 🚀"
), )),
FeatureDescription( .feature(Description(
icon: "arrow.up.message", icon: "arrow.up.message",
title: "Better messages", title: "Better messages",
description: "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." description: "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history."
), )),
FeatureDescription( .feature(Description(
icon: "lock", icon: "lock",
title: "Self-destruct passcode", title: "Self-destruct passcode",
description: "All data is erased when it is entered." description: "All data is erased when it is entered."
), )),
FeatureDescription( .feature(Description(
icon: "character", icon: "character",
title: "Japanese interface", title: "Japanese interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
), )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v5.2", version: "v5.2",
post: URL(string: "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"), post: URL(string: "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "checkmark", icon: "checkmark",
title: "Message delivery receipts!", title: "Message delivery receipts!",
description: "The second tick we missed! ✅" description: "The second tick we missed! ✅"
), )),
FeatureDescription( .feature(Description(
icon: "star", icon: "star",
title: "Find chats faster", title: "Find chats faster",
description: "Filter unread and favorite chats." description: "Filter unread and favorite chats."
), )),
FeatureDescription( .feature(Description(
icon: "exclamationmark.arrow.triangle.2.circlepath", icon: "exclamationmark.arrow.triangle.2.circlepath",
title: "Keep your connections", title: "Keep your connections",
description: "Fix encryption after restoring backups." description: "Fix encryption after restoring backups."
), )),
FeatureDescription( .feature(Description(
icon: "stopwatch", icon: "stopwatch",
title: "Make one message disappear", title: "Make one message disappear",
description: "Even when disabled in the conversation." description: "Even when disabled in the conversation."
), )),
FeatureDescription( .feature(Description(
icon: "gift", icon: "gift",
title: "A few more things", title: "A few more things",
description: "- more stable message delivery.\n- a bit better groups.\n- and more!" description: "- more stable message delivery.\n- a bit better groups.\n- and more!"
), )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v5.3", version: "v5.3",
post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"), post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "desktopcomputer", icon: "desktopcomputer",
title: "New desktop app!", title: "New desktop app!",
description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻"
), )),
FeatureDescription( .feature(Description(
icon: "lock", icon: "lock",
title: "Encrypt stored files & media", title: "Encrypt stored files & media",
description: "App encrypts new local files (except videos)." description: "App encrypts new local files (except videos)."
), )),
FeatureDescription( .feature(Description(
icon: "magnifyingglass", icon: "magnifyingglass",
title: "Discover and join groups", 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." 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", icon: "theatermasks",
title: "Simplified incognito mode", title: "Simplified incognito mode",
description: "Toggle incognito when connecting." description: "Toggle incognito when connecting."
), )),
FeatureDescription( .feature(Description(
icon: "character", icon: "character",
title: "\(4) new interface languages", 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)!" 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( VersionDescription(
version: "v5.4", version: "v5.4",
post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"), post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "desktopcomputer", icon: "desktopcomputer",
title: "Link mobile and desktop apps! 🔗", title: "Link mobile and desktop apps! 🔗",
description: "Via secure quantum resistant protocol." description: "Via secure quantum resistant protocol."
), )),
FeatureDescription( .feature(Description(
icon: "person.2", icon: "person.2",
title: "Better groups", title: "Better groups",
description: "Faster joining and more reliable messages." description: "Faster joining and more reliable messages."
), )),
FeatureDescription( .feature(Description(
icon: "theatermasks", icon: "theatermasks",
title: "Incognito groups", title: "Incognito groups",
description: "Create a group using a random profile." description: "Create a group using a random profile."
), )),
FeatureDescription( .feature(Description(
icon: "hand.raised", icon: "hand.raised",
title: "Block group members", title: "Block group members",
description: "To hide unwanted messages." description: "To hide unwanted messages."
), )),
FeatureDescription( .feature(Description(
icon: "gift", icon: "gift",
title: "A few more things", title: "A few more things",
description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!"
), )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v5.5", version: "v5.5",
post: URL(string: "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html"), post: URL(string: "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "folder", icon: "folder",
title: "Private notes", title: "Private notes",
description: "With encrypted files and media." description: "With encrypted files and media."
), )),
FeatureDescription( .feature(Description(
icon: "link", icon: "link",
title: "Paste link to connect!", title: "Paste link to connect!",
description: "Search bar accepts invitation links." description: "Search bar accepts invitation links."
), )),
FeatureDescription( .feature(Description(
icon: "bubble.left.and.bubble.right", icon: "bubble.left.and.bubble.right",
title: "Join group conversations", 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)." 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", icon: "battery.50",
title: "Improved message delivery", title: "Improved message delivery",
description: "With reduced battery usage." description: "With reduced battery usage."
), )),
FeatureDescription( .feature(Description(
icon: "character", icon: "character",
title: "Turkish interface", title: "Turkish interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
), )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v5.6", 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"), post: URL(string: "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "key", icon: "key",
title: "Quantum resistant encryption", title: "Quantum resistant encryption",
description: "Enable in direct chats (BETA)!" description: "Enable in direct chats (BETA)!"
), )),
FeatureDescription( .feature(Description(
icon: "tray.and.arrow.up", icon: "tray.and.arrow.up",
title: "App data migration", title: "App data migration",
description: "Migrate to another device via QR code." description: "Migrate to another device via QR code."
), )),
FeatureDescription( .feature(Description(
icon: "phone", icon: "phone",
title: "Picture-in-picture calls", title: "Picture-in-picture calls",
description: "Use the app while in the call." description: "Use the app while in the call."
), )),
FeatureDescription( .feature(Description(
icon: "hand.raised", icon: "hand.raised",
title: "Safer groups", title: "Safer groups",
description: "Admins can block a member for all." description: "Admins can block a member for all."
), )),
FeatureDescription( .feature(Description(
icon: "character", icon: "character",
title: "Hungarian interface", title: "Hungarian interface",
description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" description: "Thanks to the users [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
), )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v5.7", version: "v5.7",
post: URL(string: "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html"), post: URL(string: "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "key", icon: "key",
title: "Quantum resistant encryption", title: "Quantum resistant encryption",
description: "Will be enabled in direct chats!" description: "Will be enabled in direct chats!"
), )),
FeatureDescription( .feature(Description(
icon: "arrowshape.turn.up.forward", icon: "arrowshape.turn.up.forward",
title: "Forward and save messages", title: "Forward and save messages",
description: "Message source remains private." description: "Message source remains private."
), )),
FeatureDescription( .feature(Description(
icon: "music.note", icon: "music.note",
title: "In-call sounds", title: "In-call sounds",
description: "When connecting audio and video calls." description: "When connecting audio and video calls."
), )),
FeatureDescription( .feature(Description(
icon: "person.crop.square", icon: "person.crop.square",
title: "Shape profile images", title: "Shape profile images",
description: "Square, circle, or anything in between." description: "Square, circle, or anything in between."
), )),
FeatureDescription( .feature(Description(
icon: "antenna.radiowaves.left.and.right", icon: "antenna.radiowaves.left.and.right",
title: "Network management", title: "Network management",
description: "More reliable network connection." description: "More reliable network connection."
) )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v5.8", version: "v5.8",
post: URL(string: "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html"), post: URL(string: "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "arrow.forward", icon: "arrow.forward",
title: "Private message routing 🚀", title: "Private message routing 🚀",
description: "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." 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", icon: "network.badge.shield.half.filled",
title: "Safely receive files", title: "Safely receive files",
description: "Confirm files from unknown servers." description: "Confirm files from unknown servers."
), )),
FeatureDescription( .feature(Description(
icon: "battery.50", icon: "battery.50",
title: "Improved message delivery", title: "Improved message delivery",
description: "With reduced battery usage." description: "With reduced battery usage."
) )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v6.0", version: "v6.0",
post: URL(string: "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html"), post: URL(string: "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: nil, icon: nil,
title: "New chat experience 🎉", title: "New chat experience 🎉",
description: nil, description: nil,
@ -444,8 +463,8 @@ private let versionDescriptions: [VersionDescription] = [
("platter.filled.bottom.and.arrow.down.iphone", "Use the app with one hand."), ("platter.filled.bottom.and.arrow.down.iphone", "Use the app with one hand."),
("paintpalette", "Color chats with the new themes."), ("paintpalette", "Color chats with the new themes."),
] ]
), )),
FeatureDescription( .feature(Description(
icon: nil, icon: nil,
title: "New media options", title: "New media options",
description: nil, description: nil,
@ -454,39 +473,39 @@ private let versionDescriptions: [VersionDescription] = [
("play.circle", "Play from the chat list."), ("play.circle", "Play from the chat list."),
("circle.filled.pattern.diagonalline.rectangle", "Blur for better privacy.") ("circle.filled.pattern.diagonalline.rectangle", "Blur for better privacy.")
] ]
), )),
FeatureDescription( .feature(Description(
icon: "arrow.forward", icon: "arrow.forward",
title: "Private message routing 🚀", title: "Private message routing 🚀",
description: "It protects your IP address and connections." description: "It protects your IP address and connections."
), )),
FeatureDescription( .feature(Description(
icon: "network", icon: "network",
title: "Better networking", title: "Better networking",
description: "Connection and servers status." description: "Connection and servers status."
) )),
] ]
), ),
VersionDescription( VersionDescription(
version: "v6.1", version: "v6.1",
post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"), post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"),
features: [ features: [
FeatureDescription( .feature(Description(
icon: "checkmark.shield", icon: "checkmark.shield",
title: "Better security ✅", title: "Better security ✅",
description: "SimpleX protocols reviewed by Trail of Bits." description: "SimpleX protocols reviewed by Trail of Bits."
), )),
FeatureDescription( .feature(Description(
icon: "video", icon: "video",
title: "Better calls", title: "Better calls",
description: "Switch audio and video during the call." description: "Switch audio and video during the call."
), )),
FeatureDescription( .feature(Description(
icon: "bolt", icon: "bolt",
title: "Better notifications", title: "Better notifications",
description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!" description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!"
), )),
FeatureDescription( .feature(Description(
icon: nil, icon: nil,
title: "Better user experience", title: "Better user experience",
description: nil, description: nil,
@ -497,9 +516,25 @@ private let versionDescriptions: [VersionDescription] = [
("arrowshape.turn.up.right", "Forward up to 20 messages at once."), ("arrowshape.turn.up.right", "Forward up to 20 messages at once."),
("flag", "Delete or moderate up to 200 messages.") ("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 private let lastVersion = versionDescriptions.last!.version
@ -514,14 +549,68 @@ func shouldShowWhatsNew() -> Bool {
return v != lastVersion 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 { struct WhatsNewView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@State var currentVersion = versionDescriptions.count - 1 @State var currentVersion = versionDescriptions.count - 1
@State var currentVersionNav = versionDescriptions.count - 1 @State var currentVersionNav = versionDescriptions.count - 1
var viaSettings = false var viaSettings = false
var updatedConditions: Bool
@State private var sheetItem: WhatsNewViewSheet? = nil
var body: some View { 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 { VStack {
TabView(selection: $currentVersion) { TabView(selection: $currentVersion) {
ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in
@ -532,9 +621,11 @@ struct WhatsNewView: View {
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical) .padding(.vertical)
ForEach(v.features, id: \.title) { f in ForEach(v.features) { f in
featureDescription(f) switch f {
.padding(.bottom, 8) case let .feature(d): featureDescription(d).padding(.bottom, 8)
case let .view(v): AnyView(v.view()).padding(.bottom, 8)
}
} }
if let post = v.post { if let post = v.post {
Link(destination: post) { Link(destination: post) {
@ -544,13 +635,20 @@ struct WhatsNewView: View {
} }
} }
} }
if updatedConditions {
Button("View updated conditions") {
sheetItem = .showConditions
}
}
if !viaSettings { if !viaSettings {
Spacer() Spacer()
Button("Ok") { Button("Ok") {
dismiss() dismiss()
} }
.font(.title3) .font(.title3)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
Spacer() Spacer()
} }
} }
@ -568,20 +666,24 @@ struct WhatsNewView: View {
currentVersionNav = currentVersion currentVersionNav = currentVersion
} }
} }
private func featureDescription(_ f: FeatureDescription) -> some View { @ViewBuilder private func featureHeader(_ icon: String?, _ title: LocalizedStringKey) -> some View {
VStack(alignment: .leading, spacing: 4) { if let icon {
if let icon = f.icon { HStack(alignment: .center, spacing: 4) {
HStack(alignment: .center, spacing: 4) { Image(systemName: icon)
Image(systemName: icon) .symbolRenderingMode(.monochrome)
.symbolRenderingMode(.monochrome) .foregroundColor(theme.colors.secondary)
.foregroundColor(theme.colors.secondary) .frame(minWidth: 30, alignment: .center)
.frame(minWidth: 30, alignment: .center) Text(title).font(.title3).bold()
Text(f.title).font(.title3).bold()
}
} else {
Text(f.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 { if let d = f.description {
Text(d) Text(d)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
@ -636,6 +738,6 @@ struct WhatsNewView: View {
struct NewFeaturesView_Previews: PreviewProvider { struct NewFeaturesView_Previews: PreviewProvider {
static var previews: some View { 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 { struct ProtocolServerView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
let serverProtocol: ServerProtocol @Binding var userServers: [UserOperatorServers]
@Binding var server: ServerCfg @Binding var serverErrors: [UserServersError]
@State var serverToEdit: ServerCfg @Binding var server: UserServer
@State var serverToEdit: UserServer
var backLabel: LocalizedStringKey
@State private var showTestFailure = false @State private var showTestFailure = false
@State private var testing = false @State private var testing = false
@State private var testFailure: ProtocolTestFailure? @State private var testFailure: ProtocolTestFailure?
var proto: String { serverProtocol.rawValue.uppercased() }
var body: some View { var body: some View {
ZStack { ZStack {
if server.preset { if server.preset {
@ -32,9 +32,33 @@ struct ProtocolServerView: View {
ProgressView().scaleEffect(2) ProgressView().scaleEffect(2)
} }
} }
.modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) { .modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) {
server = serverToEdit if let (serverToEditProtocol, serverToEditOperator) = serverProtocolAndOperator(serverToEdit, userServers),
dismiss() 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(isPresented: $showTestFailure) {
Alert( Alert(
@ -62,7 +86,7 @@ struct ProtocolServerView: View {
private func customServer() -> some View { private func customServer() -> some View {
VStack { VStack {
let serverAddress = parseServerAddress(serverToEdit.server) let serverAddress = parseServerAddress(serverToEdit.server)
let valid = serverAddress?.valid == true && serverAddress?.serverProtocol == serverProtocol let valid = serverAddress?.valid == true
List { List {
Section { Section {
TextEditor(text: $serverToEdit.server) TextEditor(text: $serverToEdit.server)
@ -112,10 +136,7 @@ struct ProtocolServerView: View {
Spacer() Spacer()
showTestStatus(server: serverToEdit) showTestStatus(server: serverToEdit)
} }
let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset
Toggle("Use for new connections", isOn: $serverToEdit.enabled) 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 { switch server.tested {
case .some(true): case .some(true):
Image(systemName: "checkmark") 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 { do {
let r = try await testProtoServer(server: server.wrappedValue.server) let r = try await testProtoServer(server: server.wrappedValue.server)
switch r { switch r {
@ -178,9 +199,11 @@ func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailu
struct ProtocolServerView_Previews: PreviewProvider { struct ProtocolServerView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ProtocolServerView( ProtocolServerView(
serverProtocol: .smp, userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
server: Binding.constant(ServerCfg.sampleData.custom), serverErrors: Binding.constant([]),
serverToEdit: ServerCfg.sampleData.custom 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 { struct ScanProtocolServer: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@Binding var servers: [ServerCfg] @Binding var userServers: [UserOperatorServers]
@State private var showAddressError = false @Binding var serverErrors: [UserServersError]
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@ -28,23 +28,14 @@ struct ScanProtocolServer: View {
} }
.padding() .padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .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>) { func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp { switch resp {
case let .success(r): case let .success(r):
if parseServerAddress(r.string) != nil { var server: UserServer = .empty
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false)) server.server = r.string
dismiss() addServer(server, $userServers, $serverErrors, dismiss)
} else {
showAddressError = true
}
case let .failure(e): case let .failure(e):
logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)") logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)")
dismiss() dismiss()
@ -54,6 +45,9 @@ struct ScanProtocolServer: View {
struct ScanProtocolServer_Previews: PreviewProvider { struct ScanProtocolServer_Previews: PreviewProvider {
static var previews: some View { 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_ROUNDNESS = "chatItemRoundness"
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail" let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
@ -107,6 +108,7 @@ let appDefaults: [String: Any] = [
DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness, DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness,
DEFAULT_CHAT_ITEM_TAIL: true, DEFAULT_CHAT_ITEM_TAIL: true,
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false,
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
@ -135,6 +137,7 @@ let appDefaults: [String: Any] = [
let hintDefaults = [ let hintDefaults = [
DEFAULT_LA_NOTICE_SHOWN, DEFAULT_LA_NOTICE_SHOWN,
DEFAULT_ONE_HAND_UI_CARD_SHOWN, DEFAULT_ONE_HAND_UI_CARD_SHOWN,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
DEFAULT_SHOW_MUTE_PROFILE_ALERT, DEFAULT_SHOW_MUTE_PROFILE_ALERT,
@ -356,7 +359,7 @@ struct SettingsView: View {
} }
} }
NavigationLink { NavigationLink {
WhatsNewView(viaSettings: true) WhatsNewView(viaSettings: true, updatedConditions: false)
.modifier(ThemedBackground()) .modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} label: { } label: {

View file

@ -9,15 +9,47 @@
import SwiftUI import SwiftUI
struct UserAddressLearnMore: View { struct UserAddressLearnMore: View {
@State var showCreateAddressButton = false
@State private var createAddressLinkActive = false
var body: some View { var body: some View {
List { VStack {
VStack(alignment: .leading, spacing: 18) { List {
Text("You can share your address as a link or QR code - anybody can connect to you.") VStack(alignment: .leading, spacing: 18) {
Text("You won't lose your contacts if you later delete your address.") Text("You can share your address as a link or QR code - anybody can connect to you.")
Text("When people request to connect, you can accept or reject it.") Text("You won't lose your contacts if you later delete your address.")
Text("Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-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 private var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@State var shareViaProfile = false @State var shareViaProfile = false
@State var autoCreate = false
@State private var aas = AutoAcceptState() @State private var aas = AutoAcceptState()
@State private var savedAAS = AutoAcceptState() @State private var savedAAS = AutoAcceptState()
@State private var ignoreShareViaProfileChange = false @State private var ignoreShareViaProfileChange = false
@ -67,6 +68,11 @@ struct UserAddressView: View {
} }
} }
} }
.onAppear {
if chatModel.userAddress == nil, autoCreate {
createAddress()
}
}
} }
@Namespace private var bottomID @Namespace private var bottomID
@ -212,26 +218,30 @@ struct UserAddressView: View {
private func createAddressButton() -> some View { private func createAddressButton() -> some View {
Button { Button {
progressIndicator = true createAddress()
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 }
}
}
} label: { } label: {
Label("Create SimpleX address", systemImage: "qrcode") 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 { private func deleteAddressButton() -> some View {
Button(role: .destructive) { Button(role: .destructive) {

View file

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

View file

@ -72,9 +72,15 @@ public enum ChatCommand {
case apiGetGroupLink(groupId: Int64) case apiGetGroupLink(groupId: Int64)
case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64) case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64)
case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) 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 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 apiSetChatItemTTL(userId: Int64, seconds: Int64?)
case apiGetChatItemTTL(userId: Int64) case apiGetChatItemTTL(userId: Int64)
case apiSetNetworkConfig(networkConfig: NetCfg) case apiSetNetworkConfig(networkConfig: NetCfg)
@ -231,9 +237,15 @@ public enum ChatCommand {
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)" 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 .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 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 .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
@ -386,9 +398,15 @@ public enum ChatCommand {
case .apiGetGroupLink: return "apiGetGroupLink" case .apiGetGroupLink: return "apiGetGroupLink"
case .apiCreateMemberContact: return "apiCreateMemberContact" case .apiCreateMemberContact: return "apiCreateMemberContact"
case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation"
case .apiGetUserProtoServers: return "apiGetUserProtoServers"
case .apiSetUserProtoServers: return "apiSetUserProtoServers"
case .apiTestProtoServer: return "apiTestProtoServer" 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 .apiSetChatItemTTL: return "apiSetChatItemTTL"
case .apiGetChatItemTTL: return "apiGetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL"
case .apiSetNetworkConfig: return "apiSetNetworkConfig" case .apiSetNetworkConfig: return "apiSetNetworkConfig"
@ -475,10 +493,6 @@ public enum ChatCommand {
func joinedIds(_ ids: [Int64]) -> String { func joinedIds(_ ids: [Int64]) -> String {
ids.map { "\($0)" }.joined(separator: ",") ids.map { "\($0)" }.joined(separator: ",")
} }
func protoServersStr(_ servers: [ServerCfg]) -> String {
encodeJSON(ProtoServersConfig(servers: servers))
}
func chatItemTTLStr(seconds: Int64?) -> String { func chatItemTTLStr(seconds: Int64?) -> String {
if let seconds = seconds { if let seconds = seconds {
@ -548,8 +562,11 @@ public enum ChatResponse: Decodable, Error {
case apiChats(user: UserRef, chats: [ChatData]) case apiChats(user: UserRef, chats: [ChatData])
case apiChat(user: UserRef, chat: ChatData) case apiChat(user: UserRef, chat: ChatData)
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
case userProtoServers(user: UserRef, servers: UserProtoServers)
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) 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 chatItemTTL(user: UserRef, chatItemTTL: Int64?)
case networkConfig(networkConfig: NetCfg) case networkConfig(networkConfig: NetCfg)
case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?) case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?)
@ -721,8 +738,11 @@ public enum ChatResponse: Decodable, Error {
case .apiChats: return "apiChats" case .apiChats: return "apiChats"
case .apiChat: return "apiChat" case .apiChat: return "apiChat"
case .chatItemInfo: return "chatItemInfo" case .chatItemInfo: return "chatItemInfo"
case .userProtoServers: return "userProtoServers"
case .serverTestResult: return "serverTestResult" 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 .chatItemTTL: return "chatItemTTL"
case .networkConfig: return "networkConfig" case .networkConfig: return "networkConfig"
case .contactInfo: return "contactInfo" 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 .apiChats(u, chats): return withUser(u, String(describing: chats))
case let .apiChat(u, chat): return withUser(u, String(describing: chat)) 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 .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 .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 .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL))
case let .networkConfig(networkConfig): return String(describing: networkConfig) 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))") 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 public var newKey: String
} }
struct SMPServersConfig: Encodable {
var smpServers: [ServerCfg]
}
public enum ServerProtocol: String, Decodable { public enum ServerProtocol: String, Decodable {
case smp case smp
case xftp case xftp
} }
public struct ProtoServersConfig: Codable { public enum OperatorTag: String, Codable {
public var servers: [ServerCfg] case simplex = "simplex"
case flux = "flux"
case xyz = "xyz"
case demo = "demo"
} }
public struct UserProtoServers: Decodable { public struct ServerOperatorInfo: Decodable {
public var serverProtocol: ServerProtocol public var description: [String]
public var protoServers: [ServerCfg] public var website: String
public var presetServers: [ServerCfg] 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 server: String
public var preset: Bool public var preset: Bool
public var tested: Bool? public var tested: Bool?
public var enabled: Bool public var enabled: Bool
public var deleted: Bool
var createdAt = Date() 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.server = server
self.preset = preset self.preset = preset
self.tested = tested self.tested = tested
self.enabled = enabled self.enabled = enabled
self.deleted = deleted
} }
public static func == (l: ServerCfg, r: ServerCfg) -> Bool { public static func == (l: UserServer, r: UserServer) -> Bool {
l.server == r.server && l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled 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 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 { public var isEmpty: Bool {
server.trimmingCharacters(in: .whitespaces) == "" server.trimmingCharacters(in: .whitespaces) == ""
} }
public struct SampleData { public struct SampleData {
public var preset: ServerCfg public var preset: UserServer
public var custom: ServerCfg public var custom: UserServer
public var untested: ServerCfg public var untested: UserServer
public var xftpPreset: UserServer
} }
public static var sampleData = SampleData( public static var sampleData = SampleData(
preset: ServerCfg( preset: UserServer(
serverId: 1,
server: "smp://abcd@smp8.simplex.im", server: "smp://abcd@smp8.simplex.im",
preset: true, preset: true,
tested: true, tested: true,
enabled: true enabled: true,
deleted: false
), ),
custom: ServerCfg( custom: UserServer(
serverId: 2,
server: "smp://abcd@smp9.simplex.im", server: "smp://abcd@smp9.simplex.im",
preset: false, preset: false,
tested: false, tested: false,
enabled: false enabled: false,
deleted: false
), ),
untested: ServerCfg( untested: UserServer(
serverId: 3,
server: "smp://abcd@smp10.simplex.im", server: "smp://abcd@smp10.simplex.im",
preset: false, preset: false,
tested: nil, 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 { enum CodingKeys: CodingKey {
case serverId
case server case server
case preset case preset
case tested case tested
case enabled case enabled
case deleted
} }
} }
@ -1357,7 +1722,7 @@ public struct NetCfg: Codable, Equatable {
public var hostMode: HostMode = .publicHost public var hostMode: HostMode = .publicHost
public var requiredHostMode = true public var requiredHostMode = true
public var sessionMode = TransportSessionMode.user public var sessionMode = TransportSessionMode.user
public var smpProxyMode: SMPProxyMode = .unknown public var smpProxyMode: SMPProxyMode = .always
public var smpProxyFallback: SMPProxyFallback = .allowProtected public var smpProxyFallback: SMPProxyFallback = .allowProtected
public var smpWebPort = false public var smpWebPort = false
public var tcpConnectTimeout: Int // microseconds 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 android.Manifest
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import chat.simplex.common.model.ServerCfg import chat.simplex.common.model.UserServer
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
@Composable @Composable
actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) { actual fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest() cameraPermissionState.launchPermissionRequest()

View file

@ -15,7 +15,6 @@ import androidx.compose.ui.draw.*
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp 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 dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlin.math.absoluteValue
@Composable @Composable
fun AppScreen() { fun AppScreen() {
@ -194,6 +192,13 @@ fun MainScreen() {
OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
OnboardingStage.LinkAMobile -> LinkAMobile() OnboardingStage.LinkAMobile -> LinkAMobile()
OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) 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 // Ensure backwards compatibility with old onboarding stage for address creation, otherwise notification setup would be skipped
OnboardingStage.Step3_CreateSimpleXAddress -> SetNotificationsMode(chatModel) OnboardingStage.Step3_CreateSimpleXAddress -> SetNotificationsMode(chatModel)
OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)

View file

@ -145,6 +145,8 @@ object ChatModel {
val clipboardHasText = mutableStateOf(false) val clipboardHasText = mutableStateOf(false)
val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true)) val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true))
val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty)
val updatingProgress = mutableStateOf(null as Float?) val updatingProgress = mutableStateOf(null as Float?)
var updatingRequest: Closeable? = null var updatingRequest: Closeable? = null
@ -2567,6 +2569,13 @@ fun localTimestamp(t: Instant): String {
return ts.toJavaLocalDateTime().format(dateFormatter) 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 @Serializable
sealed class CIStatus { sealed class CIStatus {
@Serializable @SerialName("sndNew") class SndNew: 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.migration.MigrationFileLinkData
import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.serverHostname
import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.Yaml
import com.charleskorn.kaml.YamlConfiguration import com.charleskorn.kaml.YamlConfiguration
import chat.simplex.res.MR import chat.simplex.res.MR
import com.russhwolf.settings.Settings import com.russhwolf.settings.Settings
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@ -963,36 +965,6 @@ object ChatController {
return null 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? { suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? {
val userId = currentUserId("testProtoServer") val userId = currentUserId("testProtoServer")
val r = sendCmd(rh, CC.APITestProtoServer(userId, server)) 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 { suspend fun getChatItemTTL(rh: Long?): ChatItemTTL {
val userId = currentUserId("getChatItemTTL") val userId = currentUserId("getChatItemTTL")
val r = sendCmd(rh, CC.APIGetChatItemTTL(userId)) val r = sendCmd(rh, CC.APIGetChatItemTTL(userId))
@ -3037,9 +3109,15 @@ sealed class CC {
class APIGetGroupLink(val groupId: Long): CC() class APIGetGroupLink(val groupId: Long): CC()
class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC() class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC()
class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): 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 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 APISetChatItemTTL(val userId: Long, val seconds: Long?): CC()
class APIGetChatItemTTL(val userId: Long): CC() class APIGetChatItemTTL(val userId: Long): CC()
class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC()
@ -3197,9 +3275,15 @@ sealed class CC {
is APIGetGroupLink -> "/_get link #$groupId" is APIGetGroupLink -> "/_get link #$groupId"
is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId" is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId"
is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" 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 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 APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}"
is APIGetChatItemTTL -> "/_ttl $userId" is APIGetChatItemTTL -> "/_ttl $userId"
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
@ -3342,9 +3426,15 @@ sealed class CC {
is APIGetGroupLink -> "apiGetGroupLink" is APIGetGroupLink -> "apiGetGroupLink"
is APICreateMemberContact -> "apiCreateMemberContact" is APICreateMemberContact -> "apiCreateMemberContact"
is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" is APISendMemberContactInvitation -> "apiSendMemberContactInvitation"
is APIGetUserProtoServers -> "apiGetUserProtoServers"
is APISetUserProtoServers -> "apiSetUserProtoServers"
is APITestProtoServer -> "testProtoServer" 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 APISetChatItemTTL -> "apiSetChatItemTTL"
is APIGetChatItemTTL -> "apiGetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL"
is APISetNetworkConfig -> "apiSetNetworkConfig" is APISetNetworkConfig -> "apiSetNetworkConfig"
@ -3459,8 +3549,6 @@ sealed class CC {
companion object { companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}" 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 @Serializable
data class ProtoServersConfig( enum class OperatorTag {
val servers: List<ServerCfg> @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 @Serializable
data class UserProtocolServers( data class UsageConditionsDetail(
val serverProtocol: ServerProtocol, val conditionsId: Long,
val protoServers: List<ServerCfg>, val conditionsCommit: String,
val presetServers: List<ServerCfg>, 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 @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 remoteHostId: Long?,
val serverId: Long?,
val server: String, val server: String,
val preset: Boolean, val preset: Boolean,
val tested: Boolean? = null, val tested: Boolean? = null,
val enabled: Boolean val enabled: Boolean,
val deleted: Boolean
) { ) {
@Transient @Transient
private val createdAt: Date = Date() private val createdAt: Date = Date()
@ -3541,35 +3955,51 @@ data class ServerCfg(
get() = server.isBlank() get() = server.isBlank()
companion object { 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( class SampleData(
val preset: ServerCfg, val preset: UserServer,
val custom: ServerCfg, val custom: UserServer,
val untested: ServerCfg val untested: UserServer,
val xftpPreset: UserServer
) )
val sampleData = SampleData( val sampleData = SampleData(
preset = ServerCfg( preset = UserServer(
remoteHostId = null, remoteHostId = null,
serverId = 1,
server = "smp://abcd@smp8.simplex.im", server = "smp://abcd@smp8.simplex.im",
preset = true, preset = true,
tested = true, tested = true,
enabled = true enabled = true,
deleted = false
), ),
custom = ServerCfg( custom = UserServer(
remoteHostId = null, remoteHostId = null,
serverId = 2,
server = "smp://abcd@smp9.simplex.im", server = "smp://abcd@smp9.simplex.im",
preset = false, preset = false,
tested = false, tested = false,
enabled = false enabled = false,
deleted = false
), ),
untested = ServerCfg( untested = UserServer(
remoteHostId = null, remoteHostId = null,
serverId = 3,
server = "smp://abcd@smp10.simplex.im", server = "smp://abcd@smp10.simplex.im",
preset = false, preset = false,
tested = null, 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 hostMode: HostMode = HostMode.OnionViaSocks,
val requiredHostMode: Boolean = false, val requiredHostMode: Boolean = false,
val sessionMode: TransportSessionMode = TransportSessionMode.default, val sessionMode: TransportSessionMode = TransportSessionMode.default,
val smpProxyMode: SMPProxyMode = SMPProxyMode.Unknown, val smpProxyMode: SMPProxyMode = SMPProxyMode.Always,
val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.AllowProtected, val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.AllowProtected,
val smpWebPort: Boolean = false, val smpWebPort: Boolean = false,
val tcpConnectTimeout: Long, // microseconds 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("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("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("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("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("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR()
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): 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() @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 ApiChats -> "apiChats"
is ApiChat -> "apiChat" is ApiChat -> "apiChat"
is ApiChatItemInfo -> "chatItemInfo" is ApiChatItemInfo -> "chatItemInfo"
is UserProtoServers -> "userProtoServers"
is ServerTestResult -> "serverTestResult" is ServerTestResult -> "serverTestResult"
is ServerOperatorConditions -> "serverOperatorConditions"
is UserServers -> "userServers"
is UserServersValidation -> "userServersValidation"
is UsageConditions -> "usageConditions"
is ChatItemTTL -> "chatItemTTL" is ChatItemTTL -> "chatItemTTL"
is NetworkConfig -> "networkConfig" is NetworkConfig -> "networkConfig"
is ContactInfo -> "contactInfo" is ContactInfo -> "contactInfo"
@ -5278,8 +5714,11 @@ sealed class CR {
is ApiChats -> withUser(user, json.encodeToString(chats)) is ApiChats -> withUser(user, json.encodeToString(chats))
is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}") is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}")
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") 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 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 ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL))
is NetworkConfig -> json.encodeToString(networkConfig) is NetworkConfig -> json.encodeToString(networkConfig)
is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") 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) if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
val user = chatController.apiGetActiveUser(null) val user = chatController.apiGetActiveUser(null)
chatModel.currentUser.value = user chatModel.currentUser.value = user
chatModel.conditions.value = chatController.getServerOperators(null) ?: ServerOperatorConditionsDetail.empty
if (user == null) { if (user == null) {
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
chatModel.currentUser.value = null chatModel.currentUser.value = null
@ -137,13 +138,12 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
} }
} else if (startChat().await()) { } else if (startChat().await()) {
val savedOnboardingStage = appPreferences.onboardingStage.get() 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) { 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 { } else {
savedOnboardingStage savedOnboardingStage
} }

View file

@ -165,7 +165,7 @@ fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) {
if (!chatModel.connectedToRemote()) { if (!chatModel.connectedToRemote()) {
chatModel.localUserCreated.value = true chatModel.localUserCreated.value = true
} }
controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators)
controller.startChat(user) controller.startChat(user)
controller.switchUIRemoteHost(null) controller.switchUIRemoteHost(null)
close() 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.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) {
OnboardingStage.Step2_5_SetupDatabasePassphrase OnboardingStage.Step2_5_SetupDatabasePassphrase
} else { } else {
OnboardingStage.Step4_SetNotificationsMode OnboardingStage.Step3_ChooseServerOperators
}) })
} else { } 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, // 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 { } else {
null 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 isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } }
val itemSeparation: ItemSeparation val itemSeparation: ItemSeparation
val prevItemSeparationLargeGap: Boolean val prevItemSeparationLargeGap: Boolean

View file

@ -25,19 +25,21 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.AppLock import chat.simplex.common.AppLock
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs 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.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* 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.platform.*
import chat.simplex.common.views.call.Call import chat.simplex.common.views.call.Call
import chat.simplex.common.views.chat.item.CIFileViewScope import chat.simplex.common.views.chat.item.CIFileViewScope
import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.mkValidName import chat.simplex.common.views.mkValidName
import chat.simplex.common.views.newchat.* import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.onboarding.*
import chat.simplex.common.views.showInvalidNameAlert import chat.simplex.common.views.showInvalidNameAlert
import chat.simplex.common.views.usersettings.* 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 chat.simplex.res.MR
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -115,10 +117,26 @@ fun ToggleChatListCard() {
@Composable @Composable
fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val oneHandUI = remember { appPrefs.oneHandUI.state } val oneHandUI = remember { appPrefs.oneHandUI.state }
val rhId = chatModel.remoteHostId()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) { val showWhatsNew = shouldShowWhatsNew(chatModel)
val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false
if (showWhatsNew) {
delay(1000L) 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.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.ProtocolServersView
import chat.simplex.common.views.usersettings.SettingsPreferenceItem import chat.simplex.common.views.usersettings.SettingsPreferenceItem
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
@ -540,15 +539,8 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant,
) )
) )
} }
if (summary.known == true) { if (summary.stats != null || summary.sessions != null) {
SectionItemView(click = { SectionDividerSpaced()
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) { if (summary.stats != null) {
@ -579,12 +571,7 @@ fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, r
) )
) )
} }
if (summary.known == true) { if (summary.stats != null || summary.subs != null || summary.sessions != null) {
SectionItemView(click = {
ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.SMP, close) }
}) {
Text(generalGetString(MR.strings.open_server_settings_button))
}
SectionDividerSpaced() SectionDividerSpaced()
} }

View file

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

View file

@ -84,6 +84,7 @@ class AppBarHandler(
} }
class CollapsingAppBarNestedScrollConnection(): NestedScrollConnection { class CollapsingAppBarNestedScrollConnection(): NestedScrollConnection {
var scrollTrackingEnabled = true
var appBarOffset: Float by mutableFloatStateOf(0f) var appBarOffset: Float by mutableFloatStateOf(0f)
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { 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()) = 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) else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, if (text) 1f else alpha)
val AppBarHeight = 56.dp 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.newchat.QRCodeScanner
import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource 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 @Composable
fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current) { fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current, args: Any? = null) {
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style) Text(annotatedStringResource(stringResId, args), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style)
} }
@Composable @Composable

View file

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

View file

@ -16,8 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.NotificationsMode import chat.simplex.common.model.NotificationsMode
import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.changeNotificationsMode import chat.simplex.common.views.usersettings.changeNotificationsMode
@ -26,7 +25,7 @@ import chat.simplex.res.MR
@Composable @Composable
fun SetNotificationsMode(m: ChatModel) { fun SetNotificationsMode(m: ChatModel) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
prepareChatBeforeNotificationsSetup(m) prepareChatBeforeFinishingOnboarding()
} }
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
@ -57,6 +56,7 @@ fun SetNotificationsMode(m: ChatModel) {
onboarding = OnboardingStage.OnboardingComplete, onboarding = OnboardingStage.OnboardingComplete,
onclick = { onclick = {
changeNotificationsMode(currentMode.value, m) changeNotificationsMode(currentMode.value, m)
ModalManager.fullscreen.closeModals()
} }
) )
// Reserve space // Reserve space
@ -99,7 +99,7 @@ fun <T> SelectableCard(currentValue: State<T>, newValue: T, title: String, descr
Spacer(Modifier.height(14.dp)) 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 // 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 if (chatModel.users.any { u -> !u.user.hidden }) return
withBGApi { 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.database.* import chat.simplex.common.views.database.*
@ -36,7 +35,7 @@ fun SetupDatabasePassphrase(m: ChatModel) {
val confirmNewKey = rememberSaveable { mutableStateOf("") } val confirmNewKey = rememberSaveable { mutableStateOf("") }
fun nextStep() { fun nextStep() {
if (appPlatform.isAndroid || chatModel.currentUser.value != null) { if (appPlatform.isAndroid || chatModel.currentUser.value != null) {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators)
} else { } else {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile) m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile)
} }

View file

@ -164,14 +164,15 @@ fun OnboardingActionButton(
@Composable @Composable
fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?) { fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?) {
val state = getKeyboardState() val state = getKeyboardState()
val enabled = onClick != null
val topPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING) 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) 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) { 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(
text, text,
Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF, bottom = 5.dp), 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, fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )

View file

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

View file

@ -35,6 +35,7 @@ import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.views.newchat.QRCode
import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.validPort
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource 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.model.ChatController.appPrefs
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.database.DatabaseView
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrateFromDeviceView import chat.simplex.common.views.migration.MigrateFromDeviceView
import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.SimpleXInfo
import chat.simplex.common.views.onboarding.WhatsNewView import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.*
@Composable @Composable
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) { fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) {
@ -102,7 +101,7 @@ fun SettingsLayout(
SectionView(stringResource(MR.strings.settings_section_title_settings)) { 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(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_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_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) }) 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)) { 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_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) }) SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
if (!chatModel.desktopNoUserNoRemote) { 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) 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 SectionBottomSpacer
import SectionDividerSpaced import SectionDividerSpaced
@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.chatModel import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.usersettings.SettingsPreferenceItem
import chat.simplex.res.MR import chat.simplex.res.MR
import java.text.DecimalFormat 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 SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced import SectionDividerSpaced
import SectionItemView import SectionItemView
import SectionItemWithValue import SectionItemWithValue
@ -20,119 +21,245 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.* import androidx.compose.ui.text.*
import androidx.compose.ui.text.input.* import androidx.compose.ui.text.input.*
import androidx.compose.desktop.ui.tooling.preview.Preview 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.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs 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.model.ChatModel.controller
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* 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 chat.simplex.res.MR
import kotlinx.coroutines.launch
@Composable @Composable
fun NetworkAndServersView() { fun ModalData.NetworkAndServersView(close: () -> Unit) {
val currentRemoteHost by remember { chatModel.currentRemoteHost } 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 // 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 netCfg = remember { chatModel.controller.getNetCfg() }
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) } 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 } } val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } }
NetworkAndServersLayout( ModalView(
currentRemoteHost = currentRemoteHost, close = {
networkUseSocksProxy = networkUseSocksProxy, if (!serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)) {
onionHosts = remember { mutableStateOf(netCfg.onionHosts) }, close()
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 { } else {
AlertManager.shared.showAlertDialog( showUnsavedChangesAlert(
title = generalGetString(MR.strings.network_disable_socks), { scope.launch { saveServers(currentRemoteHost?.remoteHostId, currUserServers, userServers) }},
text = generalGetString(MR.strings.network_disable_socks_info), close
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
}
}
) )
} }
} }
) ) {
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( @Composable fun NetworkAndServersLayout(
currentRemoteHost: RemoteHostInfo?, currentRemoteHost: RemoteHostInfo?,
networkUseSocksProxy: MutableState<Boolean>, networkUseSocksProxy: MutableState<Boolean>,
onionHosts: MutableState<OnionHosts>, onionHosts: MutableState<OnionHosts>,
currUserServers: MutableState<List<UserOperatorServers>>,
serverErrors: MutableState<List<UserServersError>>,
userServers: MutableState<List<UserOperatorServers>>,
toggleSocksProxy: (Boolean) -> Unit, toggleSocksProxy: (Boolean) -> Unit,
) { ) {
val m = chatModel 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 { ColumnWithScrollBar {
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) } 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 showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) } }
AppBarTitle(stringResource(MR.strings.network_and_servers)) AppBarTitle(stringResource(MR.strings.network_and_servers))
// TODO: Review this and socks.
if (!chatModel.desktopNoUserNoRemote) { if (!chatModel.desktopNoUserNoRemote) {
SectionView(generalGetString(MR.strings.settings_section_title_messages)) { SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.message_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) } }) 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) { SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) val nullOperatorIndex = userServers.value.indexOfFirst { it.operator == null }
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 (nullOperatorIndex != -1) {
if (networkUseSocksProxy.value) { SectionItemView({
SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) ModalManager.start.showModal {
SectionDividerSpaced(maxTopPadding = true) YourServersView(
} else { userServers = userServers,
SectionDividerSpaced() 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)) { 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) } }) 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( fun showUpdateNetworkSettingsDialog(
title: String, title: String,
startsWith: 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 @Preview
@Composable @Composable
fun PreviewNetworkAndServersLayout() { fun PreviewNetworkAndServersLayout() {
@ -530,6 +917,9 @@ fun PreviewNetworkAndServersLayout() {
networkUseSocksProxy = remember { mutableStateOf(true) }, networkUseSocksProxy = remember { mutableStateOf(true) },
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) }, onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
toggleSocksProxy = {}, 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 SectionBottomSpacer
import SectionDividerSpaced import SectionDividerSpaced
import SectionItemView import SectionItemView
import SectionItemViewSpaceBetween import SectionItemViewSpaceBetween
import SectionView import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource 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.views.newchat.QRCode
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.usersettings.PreferenceToggle
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@Composable @Composable
fun ProtocolServerView(m: ChatModel, server: ServerCfg, serverProtocol: ServerProtocol, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) { fun ProtocolServerView(
var testing by remember { mutableStateOf(false) } m: ChatModel,
ProtocolServerLayout( server: UserServer,
testing, serverProtocol: ServerProtocol,
server, userServers: MutableState<List<UserOperatorServers>>,
serverProtocol, serverErrors: MutableState<List<UserServersError>>,
testServer = { onDelete: () -> Unit,
testing = true onUpdate: (UserServer) -> Unit,
withLongRunningApi { close: () -> Unit,
val res = testServerConnection(server, m) rhId: Long?
if (isActive) { ) {
onUpdate(res.first) val testing = remember { mutableStateOf(false) }
testing = 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 Box {
) ProtocolServerLayout(
if (testing) { draftServer,
Box( serverProtocol,
Modifier.fillMaxSize(), testing.value,
contentAlignment = Alignment.Center testServer = {
) { testing.value = true
CircularProgressIndicator( withLongRunningApi {
Modifier val res = testServerConnection(draftServer.value, m)
.padding(horizontal = 2.dp) if (isActive) {
.size(30.dp), draftServer.value = res.first
color = MaterialTheme.colors.secondary, testing.value = false
strokeWidth = 2.5.dp }
}
},
onDelete
) )
if (testing.value) {
DefaultProgressView(null)
}
} }
} }
} }
@Composable @Composable
private fun ProtocolServerLayout( private fun ProtocolServerLayout(
testing: Boolean, server: MutableState<UserServer>,
server: ServerCfg,
serverProtocol: ServerProtocol, serverProtocol: ServerProtocol,
testing: Boolean,
testServer: () -> Unit, testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
ColumnWithScrollBar { 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) { if (server.value.preset) {
PresetServer(testing, server, testServer, onUpdate, onDelete) PresetServer(server, testing, testServer)
} else { } else {
CustomServer(testing, server, serverProtocol, testServer, onUpdate, onDelete) CustomServer(server, testing, testServer, onDelete)
} }
SectionBottomSpacer() SectionBottomSpacer()
} }
@ -89,16 +128,14 @@ private fun ProtocolServerLayout(
@Composable @Composable
private fun PresetServer( private fun PresetServer(
server: MutableState<UserServer>,
testing: Boolean, testing: Boolean,
server: ServerCfg, testServer: () -> Unit
testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) { ) {
SectionView(stringResource(MR.strings.smp_servers_preset_address).uppercase()) { SectionView(stringResource(MR.strings.smp_servers_preset_address).uppercase()) {
SelectionContainer { SelectionContainer {
Text( Text(
server.server, server.value.server,
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
style = TextStyle( style = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 16.sp, fontFamily = FontFamily.Monospace, fontSize = 16.sp,
@ -108,23 +145,21 @@ private fun PresetServer(
} }
} }
SectionDividerSpaced() SectionDividerSpaced()
UseServerSection(true, testing, server, testServer, onUpdate, onDelete) UseServerSection(server, true, testing, testServer)
} }
@Composable @Composable
private fun CustomServer( fun CustomServer(
server: MutableState<UserServer>,
testing: Boolean, testing: Boolean,
server: ServerCfg,
serverProtocol: ServerProtocol,
testServer: () -> Unit, 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 { val valid = remember {
derivedStateOf { derivedStateOf {
with(parseServerAddress(serverAddress.value)) { with(parseServerAddress(serverAddress.value)) {
this?.valid == true && this.serverProtocol == serverProtocol this?.valid == true
} }
} }
} }
@ -142,13 +177,14 @@ private fun CustomServer(
snapshotFlow { serverAddress.value } snapshotFlow { serverAddress.value }
.distinctUntilChanged() .distinctUntilChanged()
.collect { .collect {
testedPreviously[server.server] = server.tested testedPreviously[server.value.server] = server.value.tested
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value])) server.value = server.value.copy(server = it, tested = testedPreviously[serverAddress.value])
} }
} }
} }
SectionDividerSpaced(maxTopPadding = true) SectionDividerSpaced(maxTopPadding = true)
UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete)
UseServerSection(server, valid.value, testing, testServer, onDelete)
if (valid.value) { if (valid.value) {
SectionDividerSpaced() SectionDividerSpaced()
@ -160,43 +196,44 @@ private fun CustomServer(
@Composable @Composable
private fun UseServerSection( private fun UseServerSection(
server: MutableState<UserServer>,
valid: Boolean, valid: Boolean,
testing: Boolean, testing: Boolean,
server: ServerCfg,
testServer: () -> Unit, testServer: () -> Unit,
onUpdate: (ServerCfg) -> Unit, onDelete: (() -> Unit)? = null,
onDelete: () -> Unit,
) { ) {
SectionView(stringResource(MR.strings.smp_servers_use_server).uppercase()) { SectionView(stringResource(MR.strings.smp_servers_use_server).uppercase()) {
SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) { SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) {
Text(stringResource(MR.strings.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) 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( PreferenceToggle(
stringResource(MR.strings.smp_servers_use_server_for_new_conn), stringResource(MR.strings.smp_servers_use_server_for_new_conn),
disabled = server.tested != true && !server.preset, disabled = testing,
checked = enabled.value checked = enabled.value
) { ) {
onUpdate(server.copy(enabled = it)) server.value = server.value.copy(enabled = it)
} }
SectionItemView(onDelete, disabled = testing) { if (onDelete != null) {
Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error) SectionItemView(onDelete, disabled = testing) {
Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error)
}
} }
} }
} }
@Composable @Composable
fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) = fun ShowTestStatus(server: UserServer, modifier: Modifier = Modifier) =
when (server.tested) { when (server.tested) {
true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen) true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen)
false -> Icon(painterResource(MR.images.ic_close), null, modifier, tint = MaterialTheme.colors.error) 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) 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 { try {
val r = m.controller.testProtoServer(server.remoteHostId, server.server) val r = m.controller.testProtoServer(server.remoteHostId, server.server)
server.copy(tested = r == null) to r 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.runtime.Composable
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.stringResource 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.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.platform.ColumnWithScrollBar
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.newchat.QRCodeScanner
import chat.simplex.res.MR import chat.simplex.res.MR
@Composable @Composable
expect fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) expect fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit)
@Composable @Composable
fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) { fun ScanProtocolServerLayout(rhId: Long?, onNext: (UserServer) -> Unit) {
ColumnWithScrollBar { ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr)) AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr))
QRCodeScanner { text -> QRCodeScanner { text ->
val res = parseServerAddress(text) val res = parseServerAddress(text)
if (res != null) { if (res != null) {
onNext(ServerCfg(remoteHostId = rhId, text, false, null, false)) onNext(UserServer(remoteHostId = rhId, null, text, false, null, false, false))
} else { } else {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.smp_servers_invalid_address), 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_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_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_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 --> <!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Connection timeout</string> <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_test_some_failed">Some servers failed the test:</string>
<string name="smp_servers_scan_qr">Scan server QR code</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_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_preset_server">Preset server</string>
<string name="smp_servers_your_server">Your server</string> <string name="smp_servers_your_server">Your server</string>
<string name="smp_servers_your_server_address">Your server address</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="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> <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 --> <!-- Call -->
<string name="incoming_video_call">Incoming video call</string> <string name="incoming_video_call">Incoming video call</string>
<string name="incoming_audio_call">Incoming audio 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="save_group_profile">Save group profile</string>
<string name="error_saving_group_profile">Error saving 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 --> <!-- AdvancedNetworkSettings.kt -->
<string name="network_option_tcp_connection">TCP connection</string> <string name="network_option_tcp_connection">TCP connection</string>
<string name="network_options_reset_to_defaults">Reset to defaults</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_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_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_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 --> <!-- CustomTimePicker -->
<string name="custom_time_unit_seconds">seconds</string> <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 source-repository-package
type: git type: git
location: https://github.com/simplex-chat/simplexmq.git location: https://github.com/simplex-chat/simplexmq.git
tag: a64c1aa2c41938c5e18cc49d08075f14e5d25f0d tag: 97104988a307bd27b8bf5da7ed67455f3531d7ae
source-repository-package source-repository-package
type: git 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.* - email-validate == 2.3.*
- exceptions == 0.10.* - exceptions == 0.10.*
- filepath == 1.4.* - filepath == 1.4.*
- file-embed == 0.0.15.*
- http-types == 0.12.* - http-types == 0.12.*
- http2 >= 4.2.2 && < 4.3 - http2 >= 4.2.2 && < 4.3
- memory == 0.18.* - memory == 0.18.*
@ -38,6 +39,7 @@ dependencies:
- optparse-applicative >= 0.15 && < 0.17 - optparse-applicative >= 0.15 && < 0.17
- random >= 1.1 && < 1.3 - random >= 1.1 && < 1.3
- record-hasfield == 1.0.* - record-hasfield == 1.0.*
- scientific ==0.3.7.*
- simple-logger == 0.1.* - simple-logger == 0.1.*
- simplexmq >= 5.0 - simplexmq >= 5.0
- socks == 0.6.* - socks == 0.6.*
@ -72,7 +74,7 @@ when:
- bytestring == 0.10.* - bytestring == 0.10.*
- process >= 1.6 && < 1.6.18 - process >= 1.6 && < 1.6.18
- template-haskell == 2.16.* - template-haskell == 2.16.*
- text >= 1.2.3.0 && < 1.3 - text >= 1.2.4.0 && < 1.3
library: library:
source-dirs: src 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/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "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.M20241008_indexes
Simplex.Chat.Migrations.M20241010_contact_requests_contact_id Simplex.Chat.Migrations.M20241010_contact_requests_contact_id
Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id
Simplex.Chat.Migrations.M20241027_server_operators
Simplex.Chat.Mobile Simplex.Chat.Mobile
Simplex.Chat.Mobile.File Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.Shared
Simplex.Chat.Mobile.WebRTC Simplex.Chat.Mobile.WebRTC
Simplex.Chat.Operators
Simplex.Chat.Operators.Conditions
Simplex.Chat.Options Simplex.Chat.Options
Simplex.Chat.ProfileGenerator Simplex.Chat.ProfileGenerator
Simplex.Chat.Protocol Simplex.Chat.Protocol
@ -214,6 +217,7 @@ library
, directory ==1.3.* , directory ==1.3.*
, email-validate ==2.3.* , email-validate ==2.3.*
, exceptions ==0.10.* , exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
@ -224,6 +228,7 @@ library
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3 , random >=1.1 && <1.3
, record-hasfield ==1.0.* , record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplexmq >=5.0 , simplexmq >=5.0
, socks ==0.6.* , socks ==0.6.*
@ -250,7 +255,7 @@ library
bytestring ==0.10.* bytestring ==0.10.*
, process >=1.6 && <1.6.18 , process >=1.6 && <1.6.18
, template-haskell ==2.16.* , template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3 , text >=1.2.4.0 && <1.3
executable simplex-bot executable simplex-bot
main-is: Main.hs main-is: Main.hs
@ -277,6 +282,7 @@ executable simplex-bot
, directory ==1.3.* , directory ==1.3.*
, email-validate ==2.3.* , email-validate ==2.3.*
, exceptions ==0.10.* , exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
@ -287,6 +293,7 @@ executable simplex-bot
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3 , random >=1.1 && <1.3
, record-hasfield ==1.0.* , record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplex-chat , simplex-chat
, simplexmq >=5.0 , simplexmq >=5.0
@ -314,7 +321,7 @@ executable simplex-bot
bytestring ==0.10.* bytestring ==0.10.*
, process >=1.6 && <1.6.18 , process >=1.6 && <1.6.18
, template-haskell ==2.16.* , template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3 , text >=1.2.4.0 && <1.3
executable simplex-bot-advanced executable simplex-bot-advanced
main-is: Main.hs main-is: Main.hs
@ -341,6 +348,7 @@ executable simplex-bot-advanced
, directory ==1.3.* , directory ==1.3.*
, email-validate ==2.3.* , email-validate ==2.3.*
, exceptions ==0.10.* , exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
@ -351,6 +359,7 @@ executable simplex-bot-advanced
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3 , random >=1.1 && <1.3
, record-hasfield ==1.0.* , record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplex-chat , simplex-chat
, simplexmq >=5.0 , simplexmq >=5.0
@ -378,7 +387,7 @@ executable simplex-bot-advanced
bytestring ==0.10.* bytestring ==0.10.*
, process >=1.6 && <1.6.18 , process >=1.6 && <1.6.18
, template-haskell ==2.16.* , template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3 , text >=1.2.4.0 && <1.3
executable simplex-broadcast-bot executable simplex-broadcast-bot
main-is: Main.hs main-is: Main.hs
@ -408,6 +417,7 @@ executable simplex-broadcast-bot
, directory ==1.3.* , directory ==1.3.*
, email-validate ==2.3.* , email-validate ==2.3.*
, exceptions ==0.10.* , exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
@ -418,6 +428,7 @@ executable simplex-broadcast-bot
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3 , random >=1.1 && <1.3
, record-hasfield ==1.0.* , record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplex-chat , simplex-chat
, simplexmq >=5.0 , simplexmq >=5.0
@ -445,7 +456,7 @@ executable simplex-broadcast-bot
bytestring ==0.10.* bytestring ==0.10.*
, process >=1.6 && <1.6.18 , process >=1.6 && <1.6.18
, template-haskell ==2.16.* , template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3 , text >=1.2.4.0 && <1.3
executable simplex-chat executable simplex-chat
main-is: Main.hs main-is: Main.hs
@ -473,6 +484,7 @@ executable simplex-chat
, directory ==1.3.* , directory ==1.3.*
, email-validate ==2.3.* , email-validate ==2.3.*
, exceptions ==0.10.* , exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
@ -483,6 +495,7 @@ executable simplex-chat
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3 , random >=1.1 && <1.3
, record-hasfield ==1.0.* , record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplex-chat , simplex-chat
, simplexmq >=5.0 , simplexmq >=5.0
@ -511,7 +524,7 @@ executable simplex-chat
bytestring ==0.10.* bytestring ==0.10.*
, process >=1.6 && <1.6.18 , process >=1.6 && <1.6.18
, template-haskell ==2.16.* , template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3 , text >=1.2.4.0 && <1.3
executable simplex-directory-service executable simplex-directory-service
main-is: Main.hs main-is: Main.hs
@ -544,6 +557,7 @@ executable simplex-directory-service
, directory ==1.3.* , directory ==1.3.*
, email-validate ==2.3.* , email-validate ==2.3.*
, exceptions ==0.10.* , exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
@ -554,6 +568,7 @@ executable simplex-directory-service
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3 , random >=1.1 && <1.3
, record-hasfield ==1.0.* , record-hasfield ==1.0.*
, scientific ==0.3.7.*
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplex-chat , simplex-chat
, simplexmq >=5.0 , simplexmq >=5.0
@ -581,7 +596,7 @@ executable simplex-directory-service
bytestring ==0.10.* bytestring ==0.10.*
, process >=1.6 && <1.6.18 , process >=1.6 && <1.6.18
, template-haskell ==2.16.* , template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3 , text >=1.2.4.0 && <1.3
test-suite simplex-chat-test test-suite simplex-chat-test
type: exitcode-stdio-1.0 type: exitcode-stdio-1.0
@ -603,6 +618,7 @@ test-suite simplex-chat-test
MarkdownTests MarkdownTests
MessageBatching MessageBatching
MobileTests MobileTests
OperatorTests
ProtocolTests ProtocolTests
RandomServers RandomServers
RemoteTests RemoteTests
@ -643,6 +659,7 @@ test-suite simplex-chat-test
, directory ==1.3.* , directory ==1.3.*
, email-validate ==2.3.* , email-validate ==2.3.*
, exceptions ==0.10.* , exceptions ==0.10.*
, file-embed ==0.0.15.*
, filepath ==1.4.* , filepath ==1.4.*
, generic-random ==1.5.* , generic-random ==1.5.*
, http-types ==0.12.* , http-types ==0.12.*
@ -654,6 +671,7 @@ test-suite simplex-chat-test
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, random >=1.1 && <1.3 , random >=1.1 && <1.3
, record-hasfield ==1.0.* , record-hasfield ==1.0.*
, scientific ==0.3.7.*
, silently ==1.2.* , silently ==1.2.*
, simple-logger ==0.1.* , simple-logger ==0.1.*
, simplex-chat , simplex-chat
@ -682,7 +700,7 @@ test-suite simplex-chat-test
bytestring ==0.10.* bytestring ==0.10.*
, process >=1.6 && <1.6.18 , process >=1.6 && <1.6.18
, template-haskell ==2.16.* , template-haskell ==2.16.*
, text >=1.2.3.0 && <1.3 , text >=1.2.4.0 && <1.3
if impl(ghc >= 9.6.2) if impl(ghc >= 9.6.2)
build-depends: build-depends:
hspec ==2.11.* hspec ==2.11.*

View file

@ -3,9 +3,11 @@
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-} {-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE RankNTypes #-} {-# LANGUAGE RankNTypes #-}
@ -43,7 +45,7 @@ import Data.Functor (($>))
import Data.Functor.Identity import Data.Functor.Identity
import Data.Int (Int64) import Data.Int (Int64)
import Data.List (find, foldl', isSuffixOf, mapAccumL, partition, sortOn, zipWith4) 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 qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map) import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M 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.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds)
import Data.Type.Equality
import qualified Data.UUID as UUID import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as V4 import qualified Data.UUID.V4 as V4
import Data.Word (Word32) import Data.Word (Word32)
@ -67,6 +70,7 @@ import Simplex.Chat.Messages
import Simplex.Chat.Messages.Batch (MsgBatch (..), batchMessages) import Simplex.Chat.Messages.Batch (MsgBatch (..), batchMessages)
import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Messages.CIContent.Events
import Simplex.Chat.Operators
import Simplex.Chat.Options import Simplex.Chat.Options
import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.ProfileGenerator (generateRandomProfile)
import Simplex.Chat.Protocol import Simplex.Chat.Protocol
@ -97,7 +101,7 @@ import qualified Simplex.FileTransfer.Transport as XFTP
import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId)
import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent as Agent
import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, getFastNetworkConfig, ipAddressProtected, withLockMap) 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.Lock (withLock)
import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) 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.IO (hClose, hSeek, hTell, openFile)
import UnliftIO.STM 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
defaultChatConfig = defaultChatConfig =
ChatConfig ChatConfig
@ -147,13 +179,27 @@ defaultChatConfig =
}, },
chatVRange = supportedChatVRange, chatVRange = supportedChatVRange,
confirmMigrations = MCConsole, confirmMigrations = MCConsole,
defaultServers = -- this property should NOT use operator = Nothing
DefaultAgentServers -- non-operator servers can be passed via options
{ smp = _defaultSMPServers, presetServers =
useSMP = 4, 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, ntf = _defaultNtfServers,
xftp = L.map (presetServerCfg True) defaultXFTPServers,
useXFTP = L.length defaultXFTPServers,
netCfg = defaultNetworkConfig netCfg = defaultNetworkConfig
}, },
tbqSize = 1024, tbqSize = 1024,
@ -177,29 +223,52 @@ defaultChatConfig =
chatHooks = defaultChatHooks chatHooks = defaultChatHooks
} }
_defaultSMPServers :: NonEmpty (ServerCfg 'PSMP) simplexChatSMPServers :: [NewUserServer 'PSMP]
_defaultSMPServers = simplexChatSMPServers =
L.fromList $ map
map (presetServer True)
(presetServerCfg True) [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion",
"smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion",
"smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion", "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion",
"smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion",
"smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion",
"smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion",
"smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion",
"smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion",
"smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion",
"smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", "smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.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) fluxSMPServers :: [NewUserServer 'PSMP]
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", fluxSMPServers =
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", map
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" (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 :: [NtfServer]
_defaultNtfServers = _defaultNtfServers =
@ -236,16 +305,23 @@ newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Boo
newChatController newChatController
ChatDatabase {chatStore, agentStore} ChatDatabase {chatStore, agentStore}
user 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} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable, yesToUpMigrations}, deviceName, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize}
backgroundMode = do backgroundMode = do
let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False}
confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations 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 firstTime = dbNew chatStore
currentUser <- newTVarIO user 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 currentRemoteHost <- newTVarIO Nothing
servers <- agentServers config servers <- withTransaction chatStore $ \db -> agentServers db config randomPresetServers randomAgentServers
smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode
agentAsync <- newTVarIO Nothing agentAsync <- newTVarIO Nothing
random <- liftIO C.newRandom random <- liftIO C.newRandom
@ -281,6 +357,8 @@ newChatController
ChatController ChatController
{ firstTime, { firstTime,
currentUser, currentUser,
randomPresetServers,
randomAgentServers,
currentRemoteHost, currentRemoteHost,
smpAgent, smpAgent,
agentAsync, agentAsync,
@ -318,28 +396,48 @@ newChatController
contactMergeEnabled contactMergeEnabled
} }
where where
configServers :: DefaultAgentServers presetServers' :: PresetServers
configServers = presetServers' = presetServers {operators = operators', netCfg = netCfg'}
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}
where where
getUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => [User] -> SProtocolType p -> IO (Map UserId (NonEmpty (ServerCfg p))) PresetServers {operators, netCfg} = presetServers
getUserServers users protocol = case users of netCfg' = updateNetworkConfig netCfg simpleNetCfg
[] -> pure $ M.fromList [(1, cfgServers protocol defServers)] operators' = case (smpServers, xftpServers) of
_ -> M.fromList <$> initialServers ([], []) -> operators
where (smpSrvs, []) -> L.map disableSMP operators <> [custom smpSrvs []]
initialServers :: IO [(UserId, NonEmpty (ServerCfg p))] ([], xftpSrvs) -> L.map disableXFTP operators <> [custom [] xftpSrvs]
initialServers = mapM (\u -> (aUserId u,) <$> userServers u) users (smpSrvs, xftpSrvs) -> [custom smpSrvs xftpSrvs]
userServers :: User -> IO (NonEmpty (ServerCfg p)) disableSMP op@PresetOperator {smp} = (op :: PresetOperator) {smp = map disableSrv smp}
userServers user' = useServers config protocol <$> withTransaction chatStore (`getProtocolServers` user') 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 :: NetworkConfig -> SimpleNetCfg -> NetworkConfig
updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} = 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 withFileLock name = withEntityLock name . CLFile
{-# INLINE withFileLock #-} {-# INLINE withFileLock #-}
useServers :: UserProtocol p => ChatConfig -> SProtocolType p -> [ServerCfg p] -> NonEmpty (ServerCfg p) useServers :: Foldable f => RandomAgentServers -> [(Text, ServerOperator)] -> f UserOperatorServers -> (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))
useServers ChatConfig {defaultServers} p = fromMaybe (cfgServers p defaultServers) . nonEmpty 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]) useServerCfgs :: forall p. UserProtocol p => SProtocolType p -> RandomAgentServers -> [(Text, ServerOperator)] -> [UserServer p] -> NonEmpty (ServerCfg p)
randomServers p ChatConfig {defaultServers} = do useServerCfgs p RandomAgentServers {smpServers, xftpServers} opDomains =
let srvs = cfgServers p defaultServers fromMaybe (rndAgentServers p) . L.nonEmpty . agentServerCfgs p opDomains
(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')
where 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) chooseRandomServers :: PresetServers -> IO (NonEmpty PresetOperator)
cfgServers p DefaultAgentServers {smp, xftp} = case p of chooseRandomServers PresetServers {operators} =
SPSMP -> smp forM operators $ \op -> do
SPXFTP -> xftp 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 toJustOrError :: String -> Maybe a -> IO a
cfgServersToUse p DefaultAgentServers {useSMP, useXFTP} = case p of toJustOrError name = \case
SPSMP -> useSMP Just a -> pure a
SPXFTP -> useXFTP Nothing -> do
putStrLn $ name <> ": expected Just, exiting"
E.throwIO $ userError name
-- enableSndFiles has no effect when mainApp is True -- enableSndFiles has no effect when mainApp is True
startChatController :: Bool -> Bool -> CM' (Async ()) startChatController :: Bool -> Bool -> CM' (Async ())
@ -438,7 +550,7 @@ startChatController mainApp enableSndFiles = do
startXFTP startWorkers = do startXFTP startWorkers = do
tmp <- readTVarIO =<< asks tempDirectory tmp <- readTVarIO =<< asks tempDirectory
runExceptT (withAgent $ \a -> startWorkers a tmp) >>= \case 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 () Right _ -> pure ()
startCleanupManager = do startCleanupManager = do
cleanupAsync <- asks cleanupManagerAsync cleanupAsync <- asks cleanupManagerAsync
@ -552,39 +664,43 @@ processChatCommand' vr = \case
forM_ profile $ \Profile {displayName} -> checkValidName displayName forM_ profile $ \Profile {displayName} -> checkValidName displayName
p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile
u <- asks currentUser u <- asks currentUser
(smp, smpServers) <- chooseServers SPSMP
(xftp, xftpServers) <- chooseServers SPXFTP
users <- withFastStore' getUsers users <- withFastStore' getUsers
forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash} -> forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash} ->
when (n == displayName) . throwChatError $ when (n == displayName) . throwChatError $
if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""} 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 ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure
user <- withFastStore $ \db -> createUserRecordAt db (AgentUserId auId) p True ts user <- withFastStore $ \db -> do
createPresetContactCards user `catchChatError` \_ -> pure () user <- createUserRecordAt db (AgentUserId auId) p True ts
withFastStore $ \db -> createNoteFolder db user mapM_ (setUserServers db user ts) uss
storeServers user smpServers createPresetContactCards db user `catchStoreError` \_ -> pure ()
storeServers user xftpServers createNoteFolder db user
pure user
atomically . writeTVar u $ Just user atomically . writeTVar u $ Just user
pure $ CRActiveUser user pure $ CRActiveUser user
where where
createPresetContactCards :: User -> CM () createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO ()
createPresetContactCards user = createPresetContactCards db user = do
withFastStore $ \db -> do createContact db user simplexStatusContactProfile
createContact db user simplexStatusContactProfile createContact db user simplexTeamContactProfile
createContact db user simplexTeamContactProfile chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP)))
chooseServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> CM (NonEmpty (ServerCfg p), [ServerCfg p]) chooseServers user_ = do
chooseServers protocol = as <- asks randomAgentServers
asks currentUser >>= readTVarIO >>= \case mapM (withFastStore . flip getUserServers >=> liftIO . groupByOperator) user_ >>= \case
Nothing -> asks config >>= liftIO . randomServers protocol Just uss -> do
Just user -> chosenServers =<< withFastStore' (`getProtocolServers` user) let opDomains = operatorDomains $ mapMaybe operator' uss
where uss' = map copyServers uss
chosenServers servers = do pure $ (uss',) $ useServers as opDomains uss
cfg <- asks config Nothing -> do
pure (useServers cfg protocol servers, servers) ps <- asks randomPresetServers
storeServers user servers = uss <- presetUserServers <$> withFastStore' (\db -> getUpdateServerOperators db ps True)
unless (null servers) . withFastStore $ let RandomAgentServers {smpServers = smp', xftpServers = xftp'} = as
\db -> overwriteProtocolServers db user servers 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) coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day)
day = 86400 day = 86400
ListUsers -> CRUsersList <$> withFastStore' getUsersInfo ListUsers -> CRUsersList <$> withFastStore' getUsersInfo
@ -1482,25 +1598,96 @@ processChatCommand' vr = \case
msgs <- lift $ withAgent' $ \a -> getConnectionMessages a acIds msgs <- lift $ withAgent' $ \a -> getConnectionMessages a acIds
let ntfMsgs = L.map (\msg -> receivedMsgInfo <$> msg) msgs let ntfMsgs = L.map (\msg -> receivedMsgInfo <$> msg) msgs
pure $ CRConnNtfMessages ntfMsgs pure $ CRConnNtfMessages ntfMsgs
APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do
cfg@ChatConfig {defaultServers} <- asks config srvs <- withFastStore (`getUserServers` user)
servers <- withFastStore' (`getProtocolServers` user) liftIO $ CRUserServers user <$> groupByOperator (protocolServers p srvs)
pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p (useServers cfg p servers) (cfgServers p defaultServers) SetUserProtoServers (AProtocolType (p :: SProtocolType p)) srvs -> withUser $ \user@User {userId} -> withServerProtocol p $ do
GetUserProtoServers aProtocol -> withUser $ \User {userId} -> userServers_ <- liftIO . groupByOperator =<< withFastStore (`getUserServers` user)
processChatCommand $ APIGetUserProtoServers userId aProtocol case L.nonEmpty userServers_ of
APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) Nothing -> throwChatError $ CECommandError "no servers"
| null servers || any (\ServerCfg {enabled} -> enabled) servers -> withUserId userId $ \user -> withServerProtocol p $ do Just userServers -> case srvs of
withFastStore $ \db -> overwriteProtocolServers db user servers [] -> throwChatError $ CECommandError "no servers"
cfg <- asks config _ -> do
lift $ withAgent' $ \a -> setProtocolServers a (aUserId user) $ useServers cfg p servers srvs' <- mapM aUserServer srvs
ok user processChatCommand $ APISetUserServers userId $ L.map (updatedServers p srvs') userServers
| otherwise -> withUserId userId $ \user -> pure $ chatCmdError (Just user) "all servers are disabled" where
SetUserProtoServers serversConfig -> withUser $ \User {userId} -> aUserServer :: AProtoServerWithAuth -> CM (AUserServer p)
processChatCommand $ APISetUserProtoServers userId serversConfig 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 -> APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user ->
lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a (aUserId user) server) lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a (aUserId user) server)
TestProtoServer srv -> withUser $ \User {userId} -> TestProtoServer srv -> withUser $ \User {userId} ->
processChatCommand $ APITestProtoServer userId srv 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 -> APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user ->
checkStoreNotChanged $ checkStoreNotChanged $
withChatLock "setChatItemTTL" $ do withChatLock "setChatItemTTL" $ do
@ -1753,8 +1940,9 @@ processChatCommand' vr = \case
canKeepLink (CRInvitationUri crData _) newUser = do canKeepLink (CRInvitationUri crData _) newUser = do
let ConnReqUriData {crSmpQueues = q :| _} = crData let ConnReqUriData {crSmpQueues = q :| _} = crData
SMPQueueUri {queueAddress = SMPQueueAddress {smpServer}} = q SMPQueueUri {queueAddress = SMPQueueAddress {smpServer}} = q
cfg <- asks config newUserServers <-
newUserServers <- L.map (\ServerCfg {server} -> protoServer server) . useServers cfg SPSMP <$> withFastStore' (`getProtocolServers` newUser) map protoServer' . L.filter (\ServerCfg {enabled} -> enabled)
<$> getKnownAgentServers SPSMP newUser
pure $ smpServer `elem` newUserServers pure $ smpServer `elem` newUserServers
updateConnRecord user@User {userId} conn@PendingContactConnection {customUserProfileId} newUser = do updateConnRecord user@User {userId} conn@PendingContactConnection {customUserProfileId} newUser = do
withAgent $ \a -> changeConnectionUser a (aUserId user) (aConnId' conn) (aUserId newUser) withAgent $ \a -> changeConnectionUser a (aUserId user) (aConnId' conn) (aUserId newUser)
@ -2088,7 +2276,7 @@ processChatCommand' vr = \case
where where
changeMemberRole user gInfo members m gEvent = do changeMemberRole user gInfo members m gEvent = do
let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m 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 withGroupLock "memberRole" groupId . procCmd $ do
unless (mRole == memRole) $ do unless (mRole == memRole) $ do
withFastStore' $ \db -> updateGroupMemberRole db user m memRole withFastStore' $ \db -> updateGroupMemberRole db user m memRole
@ -2486,14 +2674,15 @@ processChatCommand' vr = \case
pure $ CRAgentSubsTotal user subsTotal hasSession pure $ CRAgentSubsTotal user subsTotal hasSession
GetAgentServersSummary userId -> withUserId userId $ \user -> do GetAgentServersSummary userId -> withUserId userId $ \user -> do
agentServersSummary <- lift $ withAgent' getAgentServersSummary agentServersSummary <- lift $ withAgent' getAgentServersSummary
cfg <- asks config withStore' $ \db -> do
(users, smpServers, xftpServers) <- users <- getUsers db
withStore' $ \db -> (,,) <$> getUsers db <*> getServers db cfg user SPSMP <*> getServers db cfg user SPXFTP smpServers <- getServers db user SPSMP
let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers _defaultNtfServers xftpServers <- getServers db user SPXFTP
pure $ CRAgentServersSummary user presentedServersSummary let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers _defaultNtfServers
pure $ CRAgentServersSummary user presentedServersSummary
where where
getServers :: (ProtocolTypeI p, UserProtocol p) => DB.Connection -> ChatConfig -> User -> SProtocolType p -> IO (NonEmpty (ProtocolServer p)) getServers :: ProtocolTypeI p => DB.Connection -> User -> SProtocolType p -> IO [ProtocolServer p]
getServers db cfg user p = L.map (\ServerCfg {server} -> protoServer server) . useServers cfg p <$> getProtocolServers db user getServers db user p = map (\UserServer {server} -> protoServer server) <$> getProtocolServers db p user
ResetAgentServersStats -> withAgent resetAgentServersStats >> ok_ ResetAgentServersStats -> withAgent resetAgentServersStats >> ok_
GetAgentWorkers -> lift $ CRAgentWorkersSummary <$> withAgent' getAgentWorkersSummary GetAgentWorkers -> lift $ CRAgentWorkersSummary <$> withAgent' getAgentWorkersSummary
GetAgentWorkersDetails -> lift $ CRAgentWorkersDetails <$> withAgent' getAgentWorkersDetails GetAgentWorkersDetails -> lift $ CRAgentWorkersDetails <$> withAgent' getAgentWorkersDetails
@ -2784,6 +2973,20 @@ processChatCommand' vr = \case
withServerProtocol p action = case userProtocol p of withServerProtocol p action = case userProtocol p of
Just Dict -> action Just Dict -> action
_ -> throwChatError $ CEServerProtocol $ AProtocolType p _ -> 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 -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> CM ChatResponse
forwardFile chatName fileId sendCommand = withUser $ \user -> do forwardFile chatName fileId sendCommand = withUser $ \user -> do
withStore (\db -> getFileTransfer db user fileId) >>= \case withStore (\db -> getFileTransfer db user fileId) >>= \case
@ -3225,6 +3428,23 @@ processChatCommand' vr = \case
msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId) msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId)
CRQueueInfo user msgInfo <$> withAgent (`getConnectionQueueInfo` acId) 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) type ComposeMessageReq = (ComposedMessage, Maybe CIForwardedFrom)
contactCITimed :: Contact -> CM (Maybe CITimed) 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 S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks
getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer] getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer]
getUnknownSrvs srvs = do getUnknownSrvs srvs = do
cfg <- asks config knownSrvs <- L.map protoServer' <$> getKnownAgentServers SPXFTP user
knownSrvs <- L.map (\ServerCfg {server} -> protoServer server) . useServers cfg SPXFTP <$> withStore' (`getProtocolServers` user)
pure $ filter (`notElem` knownSrvs) srvs pure $ filter (`notElem` knownSrvs) srvs
ipProtectedForSrvs :: [XFTPServer] -> CM Bool ipProtectedForSrvs :: [XFTPServer] -> CM Bool
ipProtectedForSrvs srvs = do ipProtectedForSrvs srvs = do
@ -3626,6 +3845,17 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete}
toView $ CRChatItemUpdated user aci toView $ CRChatItemUpdated user aci
throwChatError $ CEFileNotApproved fileId unknownSrvs 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 :: CM' NetworkConfig
getNetworkConfig = withAgent' $ liftIO . getFastNetworkConfig getNetworkConfig = withAgent' $ liftIO . getFastNetworkConfig
@ -3824,7 +4054,7 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do
(sftConns, sfts) <- getSndFileTransferConns (sftConns, sfts) <- getSndFileTransferConns
(rftConns, rfts) <- getRcvFileTransferConns (rftConns, rfts) <- getRcvFileTransferConns
(pcConns, pcs) <- getPendingContactConns (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) pure (conns, cts, ucs, gs, ms, sfts, rfts, pcs)
-- subscribe using batched commands -- subscribe using batched commands
rs <- withAgent $ \a -> agentBatchSubscribe a conns rs <- withAgent $ \a -> agentBatchSubscribe a conns
@ -4632,7 +4862,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct) ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct)
SWITCH qd phase cStats -> do SWITCH qd phase cStats -> do
toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats) 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 QDRcv -> createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCESwitchQueue phase Nothing) Nothing
QDSnd -> createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing QDSnd -> createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing
RSYNC rss cryptoErr_ cStats -> RSYNC rss cryptoErr_ cStats ->
@ -4917,7 +5147,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
(Just fileDescrText, Just msgId) -> do (Just fileDescrText, Just msgId) -> do
partSize <- asks $ xftpDescrPartSize . config partSize <- asks $ xftpDescrPartSize . config
let parts = splitFileDescr partSize fileDescrText let parts = splitFileDescr partSize fileDescrText
pure . toList $ L.map (XMsgFileDescr msgId) parts pure . L.toList $ L.map (XMsgFileDescr msgId) parts
_ -> pure [] _ -> pure []
let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents
GroupMember {memberId} = sender GroupMember {memberId} = sender
@ -5043,7 +5273,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
when continued $ sendPendingGroupMessages user m conn when continued $ sendPendingGroupMessages user m conn
SWITCH qd phase cStats -> do SWITCH qd phase cStats -> do
toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) 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 QDRcv -> createInternalChatItem user (CDGroupSnd gInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing
QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing
RSYNC rss cryptoErr_ cStats -> 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" messageWarning "x.grp.mem.con: neither member is invitee"
where where
inviteeXGrpMemCon :: GroupMemberIntro -> CM () inviteeXGrpMemCon :: GroupMemberIntro -> CM ()
inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of
| introStatus == GMIntroReConnected = updateStatus introId GMIntroConnected GMIntroReConnected -> updateStatus introId GMIntroConnected
| introStatus `elem` [GMIntroToConnected, GMIntroConnected] = pure () GMIntroToConnected -> pure ()
| otherwise = updateStatus introId GMIntroToConnected GMIntroConnected -> pure ()
_ -> updateStatus introId GMIntroToConnected
forwardMemberXGrpMemCon :: GroupMemberIntro -> CM () forwardMemberXGrpMemCon :: GroupMemberIntro -> CM ()
forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of
| introStatus == GMIntroToConnected = updateStatus introId GMIntroConnected GMIntroToConnected -> updateStatus introId GMIntroConnected
| introStatus `elem` [GMIntroReConnected, GMIntroConnected] = pure () GMIntroReConnected -> pure ()
| otherwise = updateStatus introId GMIntroReConnected GMIntroConnected -> pure ()
_ -> updateStatus introId GMIntroReConnected
updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status
xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> CM () xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> CM ()
@ -8080,14 +8312,19 @@ chatCommandP =
"/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP), "/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP),
"/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP), "/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP),
"/ntf test " *> (TestProtoServer . AProtoServerWithAuth SPNTF <$> strP), "/ntf test " *> (TestProtoServer . AProtoServerWithAuth SPNTF <$> strP),
"/_servers " *> (APISetUserProtoServers <$> A.decimal <* A.space <*> srvCfgP), "/smp " *> (SetUserProtoServers (AProtocolType SPSMP) . map (AProtoServerWithAuth SPSMP) <$> protocolServersP),
"/smp " *> (SetUserProtoServers . APSC SPSMP . ProtoServersConfig . map enabledServerCfg <$> protocolServersP), "/xftp " *> (SetUserProtoServers (AProtocolType SPXFTP) . map (AProtoServerWithAuth SPXFTP) <$> 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" $> GetUserProtoServers (AProtocolType SPSMP), "/smp" $> GetUserProtoServers (AProtocolType SPSMP),
"/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), "/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 " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal),
"/ttl " *> (SetChatItemTTL <$> ciTTL), "/ttl " *> (SetChatItemTTL <$> ciTTL),
"/_ttl " *> (APIGetChatItemTTL <$> A.decimal), "/_ttl " *> (APIGetChatItemTTL <$> A.decimal),
@ -8411,6 +8648,20 @@ chatCommandP =
optional ("yes" *> A.space) *> (TMEEnableSetTTL <$> timedTTLP) optional ("yes" *> A.space) *> (TMEEnableSetTTL <$> timedTTLP)
<|> ("yes" $> TMEEnableKeepTTL) <|> ("yes" $> TMEEnableKeepTTL)
<|> ("no" $> TMEDisableKeepTTL) <|> ("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 netCfgP = do
socksProxy <- "socks=" *> ("off" $> Nothing <|> "on" $> Just defaultSocksProxyWithAuth <|> Just <$> strP) socksProxy <- "socks=" *> ("off" $> Nothing <|> "on" $> Just defaultSocksProxyWithAuth <|> Just <$> strP)
socksMode <- " socks-mode=" *> strP <|> pure SMAlways socksMode <- " socks-mode=" *> strP <|> pure SMAlways
@ -8431,7 +8682,6 @@ chatCommandP =
onOffP onOffP
(Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP))) (Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP)))
(pure Nothing) (pure Nothing)
srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP)
rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (jsonP <|> text1P)) rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (jsonP <|> text1P))
text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') text1P = safeDecodeUtf8 <$> A.takeTill (== ' ')
char_ = optional . A.char char_ = optional . A.char

View file

@ -35,7 +35,6 @@ import qualified Data.ByteArray as BA
import Data.ByteString.Char8 (ByteString) import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Char8 as B
import Data.Char (ord) import Data.Char (ord)
import Data.Constraint (Dict (..))
import Data.Int (Int64) import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty) import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map) import Data.Map.Strict (Map)
@ -57,6 +56,7 @@ import Simplex.Chat.Call
import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Markdown (MarkdownList)
import Simplex.Chat.Messages import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Operators
import Simplex.Chat.Protocol import Simplex.Chat.Protocol
import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.AppVersion
import Simplex.Chat.Remote.Types import Simplex.Chat.Remote.Types
@ -84,7 +84,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption)
import Simplex.Messaging.Encoding.String import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) 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.TMap (TMap)
import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport (TLS, simplexMQVersion)
import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost)
@ -132,7 +132,7 @@ data ChatConfig = ChatConfig
{ agentConfig :: AgentConfig, { agentConfig :: AgentConfig,
chatVRange :: VersionRangeChat, chatVRange :: VersionRangeChat,
confirmMigrations :: MigrationConfirmation, confirmMigrations :: MigrationConfirmation,
defaultServers :: DefaultAgentServers, presetServers :: PresetServers,
tbqSize :: Natural, tbqSize :: Natural,
fileChunkSize :: Integer, fileChunkSize :: Integer,
xftpDescrPartSize :: Int, xftpDescrPartSize :: Int,
@ -154,6 +154,12 @@ data ChatConfig = ChatConfig
chatHooks :: ChatHooks 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. -- The hooks can be used to extend or customize chat core in mobile or CLI clients.
data ChatHooks = ChatHooks data ChatHooks = ChatHooks
{ -- preCmdHook can be used to process or modify the commands before they are processed. { -- preCmdHook can be used to process or modify the commands before they are processed.
@ -172,14 +178,12 @@ defaultChatHooks =
eventHook = \_ -> pure eventHook = \_ -> pure
} }
data DefaultAgentServers = DefaultAgentServers data PresetServers = PresetServers
{ smp :: NonEmpty (ServerCfg 'PSMP), { operators :: NonEmpty PresetOperator,
useSMP :: Int,
ntf :: [NtfServer], ntf :: [NtfServer],
xftp :: NonEmpty (ServerCfg 'PXFTP),
useXFTP :: Int,
netCfg :: NetworkConfig netCfg :: NetworkConfig
} }
deriving (Show)
data InlineFilesConfig = InlineFilesConfig data InlineFilesConfig = InlineFilesConfig
{ offerChunks :: Integer, { offerChunks :: Integer,
@ -203,6 +207,8 @@ data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLite
data ChatController = ChatController data ChatController = ChatController
{ currentUser :: TVar (Maybe User), { currentUser :: TVar (Maybe User),
randomPresetServers :: NonEmpty PresetOperator,
randomAgentServers :: RandomAgentServers,
currentRemoteHost :: TVar (Maybe RemoteHostId), currentRemoteHost :: TVar (Maybe RemoteHostId),
firstTime :: Bool, firstTime :: Bool,
smpAgent :: AgentClient, smpAgent :: AgentClient,
@ -346,12 +352,19 @@ data ChatCommand
| APIGetGroupLink GroupId | APIGetGroupLink GroupId
| APICreateMemberContact GroupId GroupMemberId | APICreateMemberContact GroupId GroupMemberId
| APISendMemberContactInvitation {contactId :: ContactId, msgContent_ :: Maybe MsgContent} | APISendMemberContactInvitation {contactId :: ContactId, msgContent_ :: Maybe MsgContent}
| APIGetUserProtoServers UserId AProtocolType
| GetUserProtoServers AProtocolType | GetUserProtoServers AProtocolType
| APISetUserProtoServers UserId AProtoServersConfig | SetUserProtoServers AProtocolType [AProtoServerWithAuth]
| SetUserProtoServers AProtoServersConfig
| APITestProtoServer UserId AProtoServerWithAuth | APITestProtoServer UserId AProtoServerWithAuth
| TestProtoServer 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) | APISetChatItemTTL UserId (Maybe Int64)
| SetChatItemTTL (Maybe Int64) | SetChatItemTTL (Maybe Int64)
| APIGetChatItemTTL UserId | APIGetChatItemTTL UserId
@ -577,8 +590,11 @@ data ChatResponse
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
| CRChatItemId User (Maybe ChatItemId) | CRChatItemId User (Maybe ChatItemId)
| CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList}
| CRUserProtoServers {user :: User, servers :: AUserProtoServers}
| CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} | 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} | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64}
| CRNetworkConfig {networkConfig :: NetworkConfig} | CRNetworkConfig {networkConfig :: NetworkConfig}
| CRContactInfo {user :: User, contact :: Contact, connectionStats_ :: Maybe ConnectionStats, customUserProfile :: Maybe Profile} | CRContactInfo {user :: User, contact :: Contact, connectionStats_ :: Maybe ConnectionStats, customUserProfile :: Maybe Profile}
@ -943,24 +959,6 @@ instance ToJSON AgentQueueId where
toJSON = strToJSON toJSON = strToJSON
toEncoding = strToJEncoding 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} data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath}
deriving (Show) deriving (Show)
@ -1562,28 +1560,28 @@ $(JQ.deriveJSON defaultJSON ''CoreVersionInfo)
$(JQ.deriveJSON defaultJSON ''SlowSQLQuery) $(JQ.deriveJSON defaultJSON ''SlowSQLQuery)
instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where -- instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where
parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) -- parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig)
instance ProtocolTypeI p => FromJSON (UserProtoServers p) where -- instance ProtocolTypeI p => FromJSON (UserProtoServers p) where
parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers) -- parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers)
instance ProtocolTypeI p => ToJSON (UserProtoServers p) where -- instance ProtocolTypeI p => ToJSON (UserProtoServers p) where
toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers) -- toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers)
toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) -- toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers)
instance FromJSON AUserProtoServers where -- instance FromJSON AUserProtoServers where
parseJSON v = J.withObject "AUserProtoServers" parse v -- parseJSON v = J.withObject "AUserProtoServers" parse v
where -- where
parse o = do -- parse o = do
AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" -- AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol"
case userProtocol p of -- case userProtocol p of
Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v -- Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v
Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p -- Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p
instance ToJSON AUserProtoServers where -- instance ToJSON AUserProtoServers where
toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s -- toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s
toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s -- toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState) $(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 unread_chat INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE app_settings(app_settings TEXT NOT NULL); 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( CREATE INDEX contact_profiles_index ON contact_profiles(
display_name, display_name,
full_name full_name
@ -890,3 +919,10 @@ CREATE INDEX idx_received_probes_group_member_id on received_probes(
group_member_id group_member_id
); );
CREATE INDEX idx_contact_requests_contact_id ON contact_requests(contact_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 <- yesToUpMigrations <-
switch switch
( long "--yes-migrate" ( long "yes-migrate"
<> short 'y' <> short 'y'
<> help "Automatically confirm \"up\" database migrations" <> 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 qualified Data.Aeson.TH as J
import Data.List (partition) import Data.List (partition)
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map) import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust) import Data.Maybe (fromMaybe, isJust)
@ -131,7 +130,7 @@ data NtfServerSummary = NtfServerSummary
-- - users are passed to exclude hidden users from totalServersSummary; -- - users are passed to exclude hidden users from totalServersSummary;
-- - if currentUser is hidden, it should be accounted in 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 -- - 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 toPresentedServersSummary agentSummary users currentUser userSMPSrvs userXFTPSrvs userNtfSrvs = do
let (userSMPSrvsSumms, allSMPSrvsSumms) = accSMPSrvsSummaries let (userSMPSrvsSumms, allSMPSrvsSumms) = accSMPSrvsSummaries
(userSMPCurr, userSMPPrev, userSMPProx) = smpSummsIntoCategories userSMPSrvsSumms (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.M20241008_indexes
import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id
import Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_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 (..)) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations :: [(String, Query, Maybe Query)]
@ -229,7 +230,8 @@ schemaMigrations =
("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order), ("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order),
("20241008_indexes", m20241008_indexes, Just down_m20241008_indexes), ("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), ("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 -- | The list of migrations in ascending order by date

View file

@ -1,5 +1,8 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
@ -47,7 +50,17 @@ module Simplex.Chat.Store.Profiles
getContactWithoutConnViaAddress, getContactWithoutConnViaAddress,
updateUserAddressAutoAccept, updateUserAddressAutoAccept,
getProtocolServers, getProtocolServers,
overwriteProtocolServers, insertProtocolServer,
getUpdateServerOperators,
getServerOperators,
getUserServers,
setServerOperators,
getCurrentUsageConditions,
getLatestAcceptedConditions,
setConditionsNotified,
acceptConditions,
setUserServers,
setUserServers',
createCall, createCall,
deleteCalls, deleteCalls,
getCalls, getCalls,
@ -68,14 +81,16 @@ import Data.Functor (($>))
import Data.Int (Int64) import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty) import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe) import Data.Maybe (catMaybes, fromMaybe)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock (UTCTime (..), getCurrentTime) 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 Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Call import Simplex.Chat.Call
import Simplex.Chat.Messages import Simplex.Chat.Messages
import Simplex.Chat.Operators
import Simplex.Chat.Protocol import Simplex.Chat.Protocol
import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Direct
import Simplex.Chat.Store.Shared import Simplex.Chat.Store.Shared
@ -83,7 +98,7 @@ import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme 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.Protocol (ACorrId, ConnId, UserId)
import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB 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 qualified Simplex.Messaging.Crypto.Ratchet as CR
import Simplex.Messaging.Encoding.String import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON) 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.Transport.Client (TransportHost)
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8)
@ -515,42 +530,285 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do
Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply) Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply)
_ -> (False, False, Nothing) _ -> (False, False, Nothing)
getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> IO [ServerCfg p] getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> IO [UserServer p]
getProtocolServers db User {userId} = getProtocolServers db p User {userId} =
map toServerCfg map toUserServer
<$> DB.query <$> DB.query
db db
[sql| [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 FROM protocol_servers
WHERE user_id = ? AND protocol = ?; WHERE user_id = ? AND protocol = ?
|] |]
(userId, decodeLatin1 $ strEncode protocol) (userId, decodeLatin1 $ strEncode p)
where where
protocol = protocolTypeI @p toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> UserServer p
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p toUserServer (serverId, host, port, keyHash, auth_, preset, tested, enabled) =
toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) = let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) in UserServer {serverId, server, preset, tested, enabled, deleted = False}
in ServerCfg {server, preset, tested, enabled}
overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p)
overwriteProtocolServers db User {userId} servers = insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled} = do
checkConstraint SEUniqueID . ExceptT $ do DB.execute
currentTs <- getCurrentTime db
DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND protocol = ? " (userId, protocol) [sql|
forM_ servers $ \ServerCfg {server, preset, tested, enabled} -> do INSERT INTO protocol_servers
let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server (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.execute
db db
[sql| [sql|
INSERT INTO protocol_servers INSERT INTO usage_conditions
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) (usage_conditions_id, conditions_commit, notified_at, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?)
|] |]
((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, enabled, userId, currentTs, currentTs)) (conditionsId, conditionsCommit, notifiedAt, createdAt)
pure $ Right () 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 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.Connection -> User -> Call -> UTCTime -> IO ()
createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do

View file

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

View file

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

View file

@ -10,7 +10,7 @@ import Data.Maybe (fromMaybe)
import Data.Time.Clock (getCurrentTime) import Data.Time.Clock (getCurrentTime)
import Data.Time.LocalTime (getCurrentTimeZone) import Data.Time.LocalTime (getCurrentTimeZone)
import Network.Socket 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.Core
import Simplex.Chat.Options import Simplex.Chat.Options
import Simplex.Chat.Terminal 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 putStrLn $ serializeChatResponse (rh, Just user) ts tz rh r
welcome :: ChatConfig -> ChatOpts -> IO () 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_ mapM_
putStrLn putStrLn
[ versionString versionNumber, [ versionString versionNumber,

View file

@ -25,6 +25,7 @@ import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map) import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe) import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)
import Data.String
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1) import Data.Text.Encoding (decodeLatin1)
@ -42,6 +43,7 @@ import Simplex.Chat.Help
import Simplex.Chat.Markdown import Simplex.Chat.Markdown
import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages hiding (NewChatItem (..))
import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Operators
import Simplex.Chat.Protocol import Simplex.Chat.Protocol
import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange) import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange)
import Simplex.Chat.Remote.Types import Simplex.Chat.Remote.Types
@ -53,7 +55,7 @@ import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme import Simplex.Chat.Types.UITheme
import qualified Simplex.FileTransfer.Transport as XFTP import qualified Simplex.FileTransfer.Transport as XFTP
import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) 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.Protocol
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..), SocksMode (..)) 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
import Simplex.Messaging.Encoding.String import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) 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 qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Client (TransportHost (..))
import Simplex.Messaging.Util (safeDecodeUtf8, tshow) import Simplex.Messaging.Util (safeDecodeUtf8, tshow)
@ -95,8 +97,11 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRChats chats -> viewChats ts tz chats CRChats chats -> viewChats ts tz chats
CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat]
CRApiParsedMarkdown ft -> [viewJSON ft] CRApiParsedMarkdown ft -> [viewJSON ft]
CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure 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 CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl
CRNetworkConfig cfg -> viewNetworkConfig cfg CRNetworkConfig cfg -> viewNetworkConfig cfg
CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile 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" "profile is " <> if isJust viewPwdHash then "hidden" else "visible"
] ]
viewUserServers :: AUserProtoServers -> Bool -> [StyledString] viewUserServers :: UserOperatorServers -> [StyledString]
viewUserServers (AUPS UserProtoServers {serverProtocol = p, protoServers, presetServers}) testView = viewUserServers (UserOperatorServers _ [] []) = []
customServers viewUserServers UserOperatorServers {operator, smpServers, xftpServers} =
<> if testView [plain $ maybe "Your servers" shortViewOperator operator]
then [] <> viewServers SPSMP smpServers
else <> viewServers SPXFTP xftpServers
[ "",
"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)"]
where where
srvCmd = "/" <> strEncode p viewServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [UserServer p] -> [StyledString]
pName = protocolName p viewServers _ [] = []
customServers = viewServers p srvs
if null protoServers | maybe True (\ServerOperator {enabled} -> enabled) operator =
then ("no " <> pName <> " servers saved, using presets: ") : viewServers presetServers [" " <> protocolName p <> " servers" <> maybe "" ((" " <>) . viewRoles) operator]
else viewServers protoServers <> 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 :: ProtocolTypeI p => SProtocolType p -> StyledString
protocolName = plain . map toUpper . T.unpack . decodeLatin1 . strEncode protocolName = plain . map toUpper . T.unpack . decodeLatin1 . strEncode
@ -1250,6 +1271,68 @@ viewServerTestResult (AProtoServerWithAuth p _) = \case
where where
pName = protocolName p 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 :: Maybe Int64 -> [StyledString]
viewChatItemTTL = \case viewChatItemTTL = \case
Nothing -> ["old messages are not being deleted"] Nothing -> ["old messages are not being deleted"]
@ -1326,9 +1409,6 @@ viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} =
["receiving messages via: " <> viewRcvQueuesInfo rcvQueuesInfo | not $ null rcvQueuesInfo] ["receiving messages via: " <> viewRcvQueuesInfo rcvQueuesInfo | not $ null rcvQueuesInfo]
<> ["sending messages via: " <> viewSndQueuesInfo sndQueuesInfo | not $ null sndQueuesInfo] <> ["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 :: [RcvQueueInfo] -> StyledString
viewRcvQueuesInfo = plain . intercalate ", " . map showQueueInfo viewRcvQueuesInfo = plain . intercalate ", " . map showQueueInfo
where where
@ -1926,7 +2006,9 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo
then [versionString version, updateStr, "simplexmq: " <> simplexmqVersion <> parens simplexmqCommit] then [versionString version, updateStr, "simplexmq: " <> simplexmqVersion <> parens simplexmqCommit]
else [versionString version, updateStr] else [versionString version, updateStr]
where where
parens s = " (" <> s <> ")"
parens :: (IsString a, Semigroup a) => a -> a
parens s = " (" <> s <> ")"
viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] viewRemoteHosts :: [RemoteHostInfo] -> [StyledString]
viewRemoteHosts = \case viewRemoteHosts = \case

View file

@ -376,6 +376,16 @@ userName :: TestCC -> IO [Char]
userName (TestCC ChatController {currentUser} _ _ _ _ _) = userName (TestCC ChatController {currentUser} _ _ _ _ _) =
maybe "no current user" (\User {localDisplayName} -> T.unpack localDisplayName) <$> readTVarIO 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 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO ()
testChat2 = testChatCfgOpts2 testCfg testOpts testChat2 = testChatCfgOpts2 testCfg testOpts
@ -423,11 +433,10 @@ smpServerCfg =
ServerConfig ServerConfig
{ transports = [(serverPort, transport @TLS, False)], { transports = [(serverPort, transport @TLS, False)],
tbqSize = 1, tbqSize = 1,
-- serverTbqSize = 1,
msgQueueQuota = 16,
msgStoreType = AMSType SMSMemory, msgStoreType = AMSType SMSMemory,
maxJournalMsgCount = 1000, msgQueueQuota = 16,
maxJournalStateLines = 1000, maxJournalMsgCount = 24,
maxJournalStateLines = 4,
queueIdBytes = 12, queueIdBytes = 12,
msgIdBytes = 6, msgIdBytes = 6,
storeLogFile = Nothing, storeLogFile = Nothing,

View file

@ -25,7 +25,7 @@ import Database.SQLite.Simple (Only (..))
import Simplex.Chat.AppSettings (defaultAppSettings) import Simplex.Chat.AppSettings (defaultAppSettings)
import qualified Simplex.Chat.AppSettings as AS import qualified Simplex.Chat.AppSettings as AS
import Simplex.Chat.Call import Simplex.Chat.Call
import Simplex.Chat.Controller (ChatConfig (..), DefaultAgentServers (..)) import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..))
import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Messages (ChatItemId)
import Simplex.Chat.Options import Simplex.Chat.Options
import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Protocol (supportedChatVRange)
@ -85,6 +85,8 @@ chatDirectTests = do
describe "XFTP servers" $ do describe "XFTP servers" $ do
it "get and set XFTP servers" testGetSetXFTPServers it "get and set XFTP servers" testGetSetXFTPServers
it "test XFTP server connection" testTestXFTPServer 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 "async connection handshake" $ do
describe "connect when initiating client goes offline" $ do describe "connect when initiating client goes offline" $ do
it "curr" $ testAsyncInitiatingOffline testCfg testCfg it "curr" $ testAsyncInitiatingOffline testCfg testCfg
@ -240,6 +242,7 @@ testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile te
bob <##. "smp agent error: BROKER" bob <##. "smp agent error: BROKER"
withSmpServer' serverCfg' $ do withSmpServer' serverCfg' $ do
alice <## "server connected localhost ()" alice <## "server connected localhost ()"
threadDelay 250000
bob ##> ("/_connect plan 1 " <> inv) bob ##> ("/_connect plan 1 " <> inv)
bob <## "invitation link: ok to connect" bob <## "invitation link: ok to connect"
bob ##> ("/_connect 1 " <> inv) bob ##> ("/_connect 1 " <> inv)
@ -334,8 +337,8 @@ testRetryConnectingClientTimeout tmp = do
{ quotaExceededTimeout = 1, { quotaExceededTimeout = 1,
messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval} messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval}
}, },
defaultServers = presetServers =
let def@DefaultAgentServers {netCfg} = defaultServers testCfg let def@PresetServers {netCfg} = presetServers testCfg
in def {netCfg = (netCfg :: NetworkConfig) {tcpTimeout = 10}} in def {netCfg = (netCfg :: NetworkConfig) {tcpTimeout = 10}}
} }
opts' = opts' =
@ -1139,24 +1142,36 @@ testSendMultiManyBatches =
testGetSetSMPServers :: HasCallStack => FilePath -> IO () testGetSetSMPServers :: HasCallStack => FilePath -> IO ()
testGetSetSMPServers = testGetSetSMPServers =
testChat2 aliceProfile bobProfile $ testChat aliceProfile $
\alice _ -> do \alice -> do
alice #$> ("/_servers 1 smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001") 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 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 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 smp://2345-w==@smp2.example.im smp://3456-w==@smp3.example.im:5224", id, "ok")
alice ##> "/smp" alice ##> "/smp"
alice <## "smp://2345-w==@smp2.example.im" alice <## "Your servers"
alice <## "smp://3456-w==@smp3.example.im:5224" alice <## " SMP servers"
alice #$> ("/smp default", id, "ok") alice <## " smp://2345-w==@smp2.example.im"
alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001") alice <## " smp://3456-w==@smp3.example.im:5224"
testTestSMPServerConnection :: HasCallStack => FilePath -> IO () testTestSMPServerConnection :: HasCallStack => FilePath -> IO ()
testTestSMPServerConnection = testTestSMPServerConnection =
testChat2 aliceProfile bobProfile $ testChat aliceProfile $
\alice _ -> do \alice -> do
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001" alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"
alice <## "SMP server test passed" alice <## "SMP server test passed"
-- to test with password: -- to test with password:
@ -1170,24 +1185,35 @@ testTestSMPServerConnection =
testGetSetXFTPServers :: HasCallStack => FilePath -> IO () testGetSetXFTPServers :: HasCallStack => FilePath -> IO ()
testGetSetXFTPServers = testGetSetXFTPServers =
testChat2 aliceProfile bobProfile $ testChat aliceProfile $
\alice _ -> withXFTPServer $ do \alice -> withXFTPServer $ do
alice #$> ("/_servers 1 xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002") 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 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 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 xftp://2345-w==@xftp2.example.im xftp://3456-w==@xftp3.example.im:5224", id, "ok")
alice ##> "/xftp" alice ##> "/xftp"
alice <## "xftp://2345-w==@xftp2.example.im" alice <## "Your servers"
alice <## "xftp://3456-w==@xftp3.example.im:5224" alice <## " XFTP servers"
alice #$> ("/xftp default", id, "ok") alice <## " xftp://2345-w==@xftp2.example.im"
alice #$> ("/xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002") alice <## " xftp://3456-w==@xftp3.example.im:5224"
testTestXFTPServer :: HasCallStack => FilePath -> IO () testTestXFTPServer :: HasCallStack => FilePath -> IO ()
testTestXFTPServer = testTestXFTPServer =
testChat2 aliceProfile bobProfile $ testChat aliceProfile $
\alice _ -> withXFTPServer $ do \alice -> withXFTPServer $ do
alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7002" alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7002"
alice <## "XFTP server test passed" alice <## "XFTP server test passed"
-- to test with password: -- 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 <## "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" 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 :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO ()
testAsyncInitiatingOffline aliceCfg bobCfg tmp = do testAsyncInitiatingOffline aliceCfg bobCfg tmp = do
inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do
@ -1800,11 +1856,15 @@ testCreateUserSameServers =
where where
checkCustomServers alice = do checkCustomServers alice = do
alice ##> "/smp" alice ##> "/smp"
alice <## "smp://2345-w==@smp2.example.im" alice <## "Your servers"
alice <## "smp://3456-w==@smp3.example.im:5224" alice <## " SMP servers"
alice <## " smp://2345-w==@smp2.example.im"
alice <## " smp://3456-w==@smp3.example.im:5224"
alice ##> "/xftp" alice ##> "/xftp"
alice <## "xftp://2345-w==@xftp2.example.im" alice <## "Your servers"
alice <## "xftp://3456-w==@xftp3.example.im:5224" alice <## " XFTP servers"
alice <## " xftp://2345-w==@xftp2.example.im"
alice <## " xftp://3456-w==@xftp3.example.im:5224"
testDeleteUser :: HasCallStack => FilePath -> IO () testDeleteUser :: HasCallStack => FilePath -> IO ()
testDeleteUser = testDeleteUser =

View file

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

View file

@ -2,6 +2,7 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PostfixOperators #-} {-# LANGUAGE PostfixOperators #-}
{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module ChatTests.Profiles where module ChatTests.Profiles where
@ -272,6 +273,7 @@ testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile
bob <##. "smp agent error: BROKER" bob <##. "smp agent error: BROKER"
withSmpServer' serverCfg' $ do withSmpServer' serverCfg' $ do
alice <## "server connected localhost ()" alice <## "server connected localhost ()"
threadDelay 250000
bob ##> ("/_connect plan 1 " <> cLink) bob ##> ("/_connect plan 1 " <> cLink)
bob <## "contact address: ok to connect" bob <## "contact address: ok to connect"
bob ##> ("/_connect 1 " <> cLink) bob ##> ("/_connect 1 " <> cLink)
@ -1733,7 +1735,15 @@ testChangePCCUserDiffSrv tmp = do
-- Create new user with different servers -- Create new user with different servers
alice ##> "/create user alisa" alice ##> "/create user alisa"
showActiveUser alice "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" alice ##> "/user alice"
showActiveUser alice "alice (Alice)" showActiveUser alice "alice (Alice)"
-- Change connection to newly created user and use the newly created connection -- 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 NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeSynonymInstances #-}
{-# OPTIONS_GHC -Wno-orphans #-} {-# OPTIONS_GHC -Wno-orphans #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module RandomServers where module RandomServers where
import Control.Monad (replicateM) import Control.Monad (replicateM)
import qualified Data.List.NonEmpty as L import Data.Foldable (foldMap')
import Simplex.Chat (cfgServers, cfgServersToUse, defaultChatConfig, randomServers) import Data.List (sortOn)
import Simplex.Chat.Controller (ChatConfig (..)) import Data.List.NonEmpty (NonEmpty)
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..)) 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 Simplex.Messaging.Protocol (ProtoServerWithAuth (..), SProtocolType (..), UserProtocol)
import Test.Hspec import Test.Hspec
randomServersTests :: Spec randomServersTests :: Spec
randomServersTests = describe "choosig random servers" $ do randomServersTests = describe "choosig random servers" $ do
it "should choose 4 random SMP servers and keep the rest disabled" testRandomSMPServers it "should choose 4 + 3 random SMP servers and keep the rest disabled" testRandomSMPServers
it "should keep all 6 XFTP servers" testRandomXFTPServers 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 :: IO ()
testRandomSMPServers = do testRandomSMPServers = do
[srvs1, srvs2, srvs3] <- [srvs1, srvs2, srvs3] <-
replicateM 3 $ 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 (srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` False -- && to avoid rare failures
testRandomXFTPServers :: IO () testRandomXFTPServers :: IO ()
testRandomXFTPServers = do testRandomXFTPServers = do
[srvs1, srvs2, srvs3] <- [srvs1, srvs2, srvs3] <-
replicateM 3 $ replicateM 3 $
checkEnabled SPXFTP 6 True =<< randomServers SPXFTP defaultChatConfig checkEnabled SPXFTP 6 False =<< chooseRandomServers (presetServers defaultChatConfig)
(srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` True (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 :: UserProtocol p => SProtocolType p -> Int -> Bool -> NonEmpty (PresetOperator) -> IO [NewUserServer p]
checkEnabled p n allUsed (srvs, _) = do checkEnabled p n allUsed presetOps' = do
let def = defaultServers defaultChatConfig let PresetServers {operators = presetOps} = presetServers defaultChatConfig
cfgSrvs = L.sortWith server' $ cfgServers p def presetSrvs = sortOn server' $ concatMap (pServers p) presetOps
toUse = cfgServersToUse p def srvs' = sortOn server' $ concatMap (pServers p) presetOps'
srvs == cfgSrvs `shouldBe` allUsed Sum toUse = foldMap' (Sum . operatorServersToUse p) presetOps
L.map enable srvs `shouldBe` L.map enable cfgSrvs Sum toUse' = foldMap' (Sum . operatorServersToUse p) presetOps'
let enbldSrvs = L.filter (\ServerCfg {enabled} -> enabled) srvs 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 toUse `shouldBe` n
length enbldSrvs `shouldBe` n length enbldSrvs `shouldBe` n
pure enbldSrvs pure enbldSrvs
where where
server' ServerCfg {server = ProtoServerWithAuth srv _} = srv server' UserServer {server = ProtoServerWithAuth srv _} = srv
enable :: forall p. ServerCfg p -> ServerCfg p enable :: forall p. NewUserServer p -> NewUserServer p
enable srv = (srv :: ServerCfg p) {enabled = False} enable srv = (srv :: NewUserServer p) {enabled = False}

View file

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