Merge pull request #5111 from simplex-chat/server-operators
core, ui: add support for server operators
21
apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png
vendored
Normal file
After Width: | Height: | Size: 33 KiB |
21
apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Flux_logo_blue.png
vendored
Normal file
After Width: | Height: | Size: 34 KiB |
21
apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png
vendored
Normal file
After Width: | Height: | Size: 17 KiB |
|
@ -9,6 +9,18 @@ import SwiftUI
|
|||
import Intents
|
||||
import SimpleXChat
|
||||
|
||||
private enum NoticesSheet: Identifiable {
|
||||
case whatsNew(updatedConditions: Bool)
|
||||
case updatedConditions
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .whatsNew: return "whatsNew"
|
||||
case .updatedConditions: return "updatedConditions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
|
@ -30,7 +42,8 @@ struct ContentView: View {
|
|||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
|
||||
@State private var showWhatsNew = false
|
||||
@State private var noticesShown = false
|
||||
@State private var noticesSheetItem: NoticesSheet? = nil
|
||||
@State private var showChooseLAMode = false
|
||||
@State private var showSetPasscode = false
|
||||
@State private var waitingForOrPassedAuth = true
|
||||
|
@ -261,8 +274,15 @@ struct ContentView: View {
|
|||
alertManager.showAlert(laNoticeAlert())
|
||||
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
if !showWhatsNew {
|
||||
showWhatsNew = shouldShowWhatsNew()
|
||||
if !noticesShown {
|
||||
let showWhatsNew = shouldShowWhatsNew()
|
||||
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
|
||||
noticesShown = showWhatsNew || showUpdatedConditions
|
||||
if showWhatsNew {
|
||||
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
|
||||
} else if showUpdatedConditions {
|
||||
noticesSheetItem = .updatedConditions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -270,8 +290,22 @@ struct ContentView: View {
|
|||
connectViaUrl()
|
||||
}
|
||||
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
|
||||
.sheet(isPresented: $showWhatsNew) {
|
||||
WhatsNewView()
|
||||
.sheet(item: $noticesSheetItem) { item in
|
||||
switch item {
|
||||
case let .whatsNew(updatedConditions):
|
||||
WhatsNewView(updatedConditions: updatedConditions)
|
||||
.modifier(ThemedBackground())
|
||||
.if(updatedConditions) { v in
|
||||
v.task { await setConditionsNotified_() }
|
||||
}
|
||||
case .updatedConditions:
|
||||
UsageConditionsView(
|
||||
currUserServers: Binding.constant([]),
|
||||
userServers: Binding.constant([])
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.task { await setConditionsNotified_() }
|
||||
}
|
||||
}
|
||||
if chatModel.setDeliveryReceipts {
|
||||
SetDeliveryReceiptsView()
|
||||
|
@ -283,6 +317,15 @@ struct ContentView: View {
|
|||
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity)
|
||||
}
|
||||
|
||||
private func setConditionsNotified_() async {
|
||||
do {
|
||||
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
|
||||
try await setConditionsNotified(conditionsId: conditionsId)
|
||||
} catch let error {
|
||||
logger.error("setConditionsNotified error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
private func processUserActivity(_ activity: NSUserActivity) {
|
||||
let intent = activity.interaction?.intent
|
||||
if let intent = intent as? INStartCallIntent {
|
||||
|
|
|
@ -193,6 +193,8 @@ final class ChatModel: ObservableObject {
|
|||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
||||
// usage conditions
|
||||
@Published var conditions: ServerOperatorConditions = .empty
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
|
||||
|
|
|
@ -500,18 +500,6 @@ func apiDeleteToken(token: DeviceToken) async throws {
|
|||
try await sendCommandOkResp(.apiDeleteToken(token: token))
|
||||
}
|
||||
|
||||
func getUserProtoServers(_ serverProtocol: ServerProtocol) throws -> UserProtoServers {
|
||||
let userId = try currentUserId("getUserProtoServers")
|
||||
let r = chatSendCmdSync(.apiGetUserProtoServers(userId: userId, serverProtocol: serverProtocol))
|
||||
if case let .userProtoServers(_, servers) = r { return servers }
|
||||
throw r
|
||||
}
|
||||
|
||||
func setUserProtoServers(_ serverProtocol: ServerProtocol, servers: [ServerCfg]) async throws {
|
||||
let userId = try currentUserId("setUserProtoServers")
|
||||
try await sendCommandOkResp(.apiSetUserProtoServers(userId: userId, serverProtocol: serverProtocol, servers: servers))
|
||||
}
|
||||
|
||||
func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> {
|
||||
let userId = try currentUserId("testProtoServer")
|
||||
let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
|
||||
|
@ -524,6 +512,65 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail
|
|||
throw r
|
||||
}
|
||||
|
||||
func getServerOperators() throws -> ServerOperatorConditions {
|
||||
let r = chatSendCmdSync(.apiGetServerOperators)
|
||||
if case let .serverOperatorConditions(conditions) = r { return conditions }
|
||||
logger.error("getServerOperators error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions {
|
||||
let r = await chatSendCmd(.apiSetServerOperators(operators: operators))
|
||||
if case let .serverOperatorConditions(conditions) = r { return conditions }
|
||||
logger.error("setServerOperators error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func getUserServers() async throws -> [UserOperatorServers] {
|
||||
let userId = try currentUserId("getUserServers")
|
||||
let r = await chatSendCmd(.apiGetUserServers(userId: userId))
|
||||
if case let .userServers(_, userServers) = r { return userServers }
|
||||
logger.error("getUserServers error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func setUserServers(userServers: [UserOperatorServers]) async throws {
|
||||
let userId = try currentUserId("setUserServers")
|
||||
let r = await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers))
|
||||
if case .cmdOk = r { return }
|
||||
logger.error("setUserServers error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] {
|
||||
let userId = try currentUserId("validateServers")
|
||||
let r = await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers))
|
||||
if case let .userServersValidation(_, serverErrors) = r { return serverErrors }
|
||||
logger.error("validateServers error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) {
|
||||
let r = await chatSendCmd(.apiGetUsageConditions)
|
||||
if case let .usageConditions(usageConditions, conditionsText, acceptedConditions) = r { return (usageConditions, conditionsText, acceptedConditions) }
|
||||
logger.error("getUsageConditions error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func setConditionsNotified(conditionsId: Int64) async throws {
|
||||
let r = await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId))
|
||||
if case .cmdOk = r { return }
|
||||
logger.error("setConditionsNotified error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions {
|
||||
let r = await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds))
|
||||
if case let .serverOperatorConditions(conditions) = r { return conditions }
|
||||
logger.error("acceptConditions error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func getChatItemTTL() throws -> ChatItemTTL {
|
||||
let userId = try currentUserId("getChatItemTTL")
|
||||
return try chatItemTTLResponse(chatSendCmdSync(.apiGetChatItemTTL(userId: userId)))
|
||||
|
@ -1558,6 +1605,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
|
|||
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
||||
m.chatInitialized = true
|
||||
m.currentUser = try apiGetActiveUser()
|
||||
m.conditions = try getServerOperators()
|
||||
if m.currentUser == nil {
|
||||
onboardingStageDefault.set(.step1_SimpleXInfo)
|
||||
privacyDeliveryReceiptsSet.set(true)
|
||||
|
@ -1602,7 +1650,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws {
|
|||
}
|
||||
}
|
||||
|
||||
func startChat(refreshInvitations: Bool = true) throws {
|
||||
func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws {
|
||||
logger.debug("startChat")
|
||||
let m = ChatModel.shared
|
||||
try setNetworkConfig(getNetCfg())
|
||||
|
@ -1621,13 +1669,15 @@ func startChat(refreshInvitations: Bool = true) throws {
|
|||
if let token = m.deviceToken {
|
||||
registerToken(token: token)
|
||||
}
|
||||
withAnimation {
|
||||
let savedOnboardingStage = onboardingStageDefault.get()
|
||||
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
|
||||
? .step3_CreateSimpleXAddress
|
||||
: savedOnboardingStage
|
||||
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
|
||||
m.setDeliveryReceipts = true
|
||||
if !onboarding {
|
||||
withAnimation {
|
||||
let savedOnboardingStage = onboardingStageDefault.get()
|
||||
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
|
||||
? .step3_ChooseServerOperators
|
||||
: savedOnboardingStage
|
||||
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
|
||||
m.setDeliveryReceipts = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,9 +31,21 @@ enum UserPickerSheet: Identifiable {
|
|||
}
|
||||
}
|
||||
|
||||
class SaveableSettings: ObservableObject {
|
||||
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
|
||||
}
|
||||
|
||||
struct ServerSettings {
|
||||
public var currUserServers: [UserOperatorServers]
|
||||
public var userServers: [UserOperatorServers]
|
||||
public var serverErrors: [UserServersError]
|
||||
}
|
||||
|
||||
struct UserPickerSheetView: View {
|
||||
let sheet: UserPickerSheet
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@StateObject private var ss = SaveableSettings()
|
||||
|
||||
@State private var loaded = false
|
||||
|
||||
var body: some View {
|
||||
|
@ -76,6 +88,21 @@ struct UserPickerSheetView: View {
|
|||
{ loaded = true }
|
||||
)
|
||||
}
|
||||
.onDisappear {
|
||||
if serversCanBeSaved(
|
||||
ss.servers.currUserServers,
|
||||
ss.servers.userServers,
|
||||
ss.servers.serverErrors
|
||||
) {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Save servers?", comment: "alert title"),
|
||||
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
|
||||
buttonAction: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) },
|
||||
cancelButton: true
|
||||
)
|
||||
}
|
||||
}
|
||||
.environmentObject(ss)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -94,6 +121,7 @@ struct ChatListView: View {
|
|||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
|
||||
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
|
||||
var body: some View {
|
||||
|
@ -276,12 +304,6 @@ struct ChatListView: View {
|
|||
.padding(.top, oneHandUI ? 8 : 0)
|
||||
.id("searchBar")
|
||||
}
|
||||
if !oneHandUICardShown {
|
||||
OneHandUICard()
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
|
@ -307,6 +329,20 @@ struct ChatListView: View {
|
|||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
}
|
||||
}
|
||||
if !oneHandUICardShown {
|
||||
OneHandUICard()
|
||||
.padding(.vertical, 6)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
if !addressCreationCardShown {
|
||||
AddressCreationCard()
|
||||
.padding(.vertical, 6)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: chatModel.chatId) { currentChatId in
|
||||
|
|
|
@ -32,7 +32,6 @@ struct OneHandUICard: View {
|
|||
.background(theme.appColors.sentMessage)
|
||||
.cornerRadius(12)
|
||||
.frame(height: dynamicSize(userFont).rowHeight)
|
||||
.padding(.vertical, 12)
|
||||
.alert(isPresented: $showOneHandUIAlert) {
|
||||
Alert(
|
||||
title: Text("Reachable chat toolbar"),
|
||||
|
|
|
@ -491,15 +491,6 @@ struct SMPServerSummaryView: View {
|
|||
Section("Server address") {
|
||||
Text(summary.smpServer)
|
||||
.textSelection(.enabled)
|
||||
if summary.known == true {
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .smp)
|
||||
.navigationTitle("Your SMP servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Open server settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let stats = summary.stats {
|
||||
|
@ -679,15 +670,6 @@ struct XFTPServerSummaryView: View {
|
|||
Section("Server address") {
|
||||
Text(summary.xftpServer)
|
||||
.textSelection(.enabled)
|
||||
if summary.known == true {
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .xftp)
|
||||
.navigationTitle("Your XFTP servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Open server settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let stats = summary.stats {
|
||||
|
|
115
apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift
Normal 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()
|
||||
}
|
436
apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift
Normal 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)
|
||||
}
|
|
@ -38,7 +38,7 @@ struct CreateProfile: View {
|
|||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
Button {
|
||||
createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss)
|
||||
createProfile()
|
||||
} label: {
|
||||
Label("Create profile", systemImage: "checkmark")
|
||||
}
|
||||
|
@ -78,6 +78,35 @@ struct CreateProfile: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createProfile() {
|
||||
hideKeyboard()
|
||||
let profile = Profile(
|
||||
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
||||
fullName: ""
|
||||
)
|
||||
let m = ChatModel.shared
|
||||
do {
|
||||
AppChatState.shared.set(.active)
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
// .isEmpty check is redundant here, but it makes it clearer what is going on
|
||||
if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) {
|
||||
try startChat()
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(.step3_ChooseServerOperators)
|
||||
m.onboardingStage = .step3_ChooseServerOperators
|
||||
}
|
||||
} else {
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
dismiss()
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
}
|
||||
} catch let error {
|
||||
showCreateProfileAlert(showAlert: { alert = $0 }, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateFirstProfile: View {
|
||||
|
@ -86,40 +115,47 @@ struct CreateFirstProfile: View {
|
|||
@Environment(\.dismiss) var dismiss
|
||||
@State private var displayName: String = ""
|
||||
@FocusState private var focusDisplayName
|
||||
@State private var nextStepNavLinkActive = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
Text("Create your profile")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Text("The profile is only shared with your contacts.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.bottom)
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Text("The profile is only shared with your contacts.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
HStack {
|
||||
let name = displayName.trimmingCharacters(in: .whitespaces)
|
||||
let validName = mkValidName(name)
|
||||
if name != validName {
|
||||
Button {
|
||||
showAlert(.invalidNameError(validName: validName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
ZStack {
|
||||
if name != validName {
|
||||
Button {
|
||||
showAlert(.invalidNameError(validName: validName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
}
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
.padding(.leading, 32)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(uiColor: .tertiarySystemFill))
|
||||
)
|
||||
}
|
||||
.padding(.bottom)
|
||||
.padding(.top)
|
||||
|
||||
Spacer()
|
||||
onboardingButtons()
|
||||
|
||||
createProfileButton()
|
||||
.padding(.bottom)
|
||||
}
|
||||
.onAppear() {
|
||||
focusDisplayName = true
|
||||
|
@ -129,88 +165,85 @@ struct CreateFirstProfile: View {
|
|||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
func onboardingButtons() -> some View {
|
||||
HStack {
|
||||
func createProfileButton() -> some View {
|
||||
ZStack {
|
||||
Button {
|
||||
hideKeyboard()
|
||||
withAnimation {
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
}
|
||||
createProfile()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "lessthan")
|
||||
Text("About SimpleX")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Create")
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
Text("Create profile")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName)))
|
||||
.disabled(!canCreateProfile(displayName))
|
||||
|
||||
NavigationLink(isActive: $nextStepNavLinkActive) {
|
||||
nextStepDestinationView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
|
||||
private func showAlert(_ alert: UserProfileAlert) {
|
||||
AlertManager.shared.showAlert(userProfileAlert(alert, $displayName))
|
||||
}
|
||||
|
||||
private func nextStepDestinationView() -> some View {
|
||||
ChooseServerOperators(onboarding: true)
|
||||
.navigationTitle("Choose operators")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
private func createProfile() {
|
||||
hideKeyboard()
|
||||
let profile = Profile(
|
||||
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
||||
fullName: ""
|
||||
)
|
||||
let m = ChatModel.shared
|
||||
do {
|
||||
AppChatState.shared.set(.active)
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
try startChat(onboarding: true)
|
||||
onboardingStageDefault.set(.step3_ChooseServerOperators)
|
||||
nextStepNavLinkActive = true
|
||||
} catch let error {
|
||||
showCreateProfileAlert(showAlert: showAlert, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) {
|
||||
hideKeyboard()
|
||||
let profile = Profile(
|
||||
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
||||
fullName: ""
|
||||
)
|
||||
private func showCreateProfileAlert(
|
||||
showAlert: (UserProfileAlert) -> Void,
|
||||
_ error: Error
|
||||
) {
|
||||
let m = ChatModel.shared
|
||||
do {
|
||||
AppChatState.shared.set(.active)
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
// .isEmpty check is redundant here, but it makes it clearer what is going on
|
||||
if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) {
|
||||
try startChat()
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(.step3_CreateSimpleXAddress)
|
||||
m.onboardingStage = .step3_CreateSimpleXAddress
|
||||
}
|
||||
switch error as? ChatResponse {
|
||||
case .chatCmdError(_, .errorStore(.duplicateName)),
|
||||
.chatCmdError(_, .error(.userExists)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(duplicateUserAlert)
|
||||
} else {
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
dismiss()
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
showAlert(.duplicateUserError)
|
||||
}
|
||||
} catch let error {
|
||||
switch error as? ChatResponse {
|
||||
case .chatCmdError(_, .errorStore(.duplicateName)),
|
||||
.chatCmdError(_, .error(.userExists)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(duplicateUserAlert)
|
||||
} else {
|
||||
showAlert(.duplicateUserError)
|
||||
}
|
||||
case .chatCmdError(_, .error(.invalidDisplayName)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(invalidDisplayNameAlert)
|
||||
} else {
|
||||
showAlert(.invalidDisplayNameError)
|
||||
}
|
||||
default:
|
||||
let err: LocalizedStringKey = "Error: \(responseError(error))"
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(creatUserErrorAlert(err))
|
||||
} else {
|
||||
showAlert(.createUserError(error: err))
|
||||
}
|
||||
case .chatCmdError(_, .error(.invalidDisplayName)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(invalidDisplayNameAlert)
|
||||
} else {
|
||||
showAlert(.invalidDisplayNameError)
|
||||
}
|
||||
default:
|
||||
let err: LocalizedStringKey = "Error: \(responseError(error))"
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(creatUserErrorAlert(err))
|
||||
} else {
|
||||
showAlert(.createUserError(error: err))
|
||||
}
|
||||
logger.error("Failed to create user or start chat: \(responseError(error))")
|
||||
}
|
||||
logger.error("Failed to create user or start chat: \(responseError(error))")
|
||||
}
|
||||
|
||||
private func canCreateProfile(_ displayName: String) -> Bool {
|
||||
|
|
|
@ -9,13 +9,16 @@
|
|||
import SwiftUI
|
||||
|
||||
struct HowItWorks: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var onboarding: Bool
|
||||
@Binding var createProfileNavLinkActive: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("How SimpleX works")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
|
@ -37,8 +40,8 @@ struct HowItWorks: View {
|
|||
Spacer()
|
||||
|
||||
if onboarding {
|
||||
OnboardingActionButton()
|
||||
.padding(.bottom, 8)
|
||||
createFirstProfileButton()
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
.lineLimit(10)
|
||||
|
@ -46,10 +49,23 @@ struct HowItWorks: View {
|
|||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
private func createFirstProfileButton() -> some View {
|
||||
Button {
|
||||
dismiss()
|
||||
createProfileNavLinkActive = true
|
||||
} label: {
|
||||
Text("Create your profile")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
}
|
||||
}
|
||||
|
||||
struct HowItWorks_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
HowItWorks(onboarding: true)
|
||||
HowItWorks(
|
||||
onboarding: true,
|
||||
createProfileNavLinkActive: Binding.constant(false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,20 +12,39 @@ struct OnboardingView: View {
|
|||
var onboarding: OnboardingStage
|
||||
|
||||
var body: some View {
|
||||
switch onboarding {
|
||||
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
|
||||
case .step2_CreateProfile: CreateFirstProfile()
|
||||
case .step3_CreateSimpleXAddress: CreateSimpleXAddress()
|
||||
case .step4_SetNotificationsMode: SetNotificationsMode()
|
||||
case .onboardingComplete: EmptyView()
|
||||
NavigationView {
|
||||
switch onboarding {
|
||||
case .step1_SimpleXInfo:
|
||||
SimpleXInfo(onboarding: true)
|
||||
.modifier(ThemedBackground())
|
||||
case .step2_CreateProfile: // deprecated
|
||||
CreateFirstProfile()
|
||||
.modifier(ThemedBackground())
|
||||
case .step3_CreateSimpleXAddress: // deprecated
|
||||
CreateSimpleXAddress()
|
||||
case .step3_ChooseServerOperators:
|
||||
ChooseServerOperators(onboarding: true)
|
||||
.navigationTitle("Choose operators")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
case .step4_SetNotificationsMode:
|
||||
SetNotificationsMode()
|
||||
.navigationTitle("Push notifications")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
case .onboardingComplete: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OnboardingStage: String, Identifiable {
|
||||
case step1_SimpleXInfo
|
||||
case step2_CreateProfile
|
||||
case step3_CreateSimpleXAddress
|
||||
case step2_CreateProfile // deprecated
|
||||
case step3_CreateSimpleXAddress // deprecated
|
||||
case step3_ChooseServerOperators
|
||||
case step4_SetNotificationsMode
|
||||
case onboardingComplete
|
||||
|
||||
|
|
|
@ -15,41 +15,39 @@ struct SetNotificationsMode: View {
|
|||
@State private var showAlert: NotificationAlert?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Push notifications")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Text("Send notifications:")
|
||||
ForEach(NotificationsMode.values) { mode in
|
||||
NtfModeSelector(mode: mode, selection: $notificationMode)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
if let token = m.deviceToken {
|
||||
setNotificationsMode(token, notificationMode)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(title: "No device token!")
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Send notifications:")
|
||||
ForEach(NotificationsMode.values) { mode in
|
||||
NtfModeSelector(mode: mode, selection: $notificationMode)
|
||||
}
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
} label: {
|
||||
if case .off = notificationMode {
|
||||
Text("Use chat")
|
||||
} else {
|
||||
Text("Enable notifications")
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
if let token = m.deviceToken {
|
||||
setNotificationsMode(token, notificationMode)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(title: "No device token!")
|
||||
}
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
} label: {
|
||||
if case .off = notificationMode {
|
||||
Text("Use chat")
|
||||
} else {
|
||||
Text("Enable notifications")
|
||||
}
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle())
|
||||
.padding(.bottom)
|
||||
}
|
||||
.font(.title)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) {
|
||||
|
|
|
@ -13,12 +13,13 @@ struct SimpleXInfo: View {
|
|||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@State private var showHowItWorks = false
|
||||
@State private var createProfileNavLinkActive = false
|
||||
var onboarding: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Image(colorScheme == .light ? "logo" : "logo-light")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
|
@ -42,9 +43,9 @@ struct SimpleXInfo: View {
|
|||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if onboarding {
|
||||
OnboardingActionButton()
|
||||
Spacer()
|
||||
createFirstProfileButton()
|
||||
|
||||
Button {
|
||||
m.migrationState = .pasteOrScanLink
|
||||
|
@ -52,7 +53,6 @@ struct SimpleXInfo: View {
|
|||
Label("Migrate from another device", systemImage: "tray.and.arrow.down")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
|
@ -62,9 +62,8 @@ struct SimpleXInfo: View {
|
|||
Label("How it works", systemImage: "info.circle")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
.padding(.bottom)
|
||||
}
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
|
@ -83,7 +82,10 @@ struct SimpleXInfo: View {
|
|||
}
|
||||
}
|
||||
.sheet(isPresented: $showHowItWorks) {
|
||||
HowItWorks(onboarding: onboarding)
|
||||
HowItWorks(
|
||||
onboarding: onboarding,
|
||||
createProfileNavLinkActive: $createProfileNavLinkActive
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
|
@ -108,49 +110,31 @@ struct SimpleXInfo: View {
|
|||
.padding(.bottom, 20)
|
||||
.padding(.trailing, 6)
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingActionButton: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private func createFirstProfileButton() -> some View {
|
||||
ZStack {
|
||||
Button {
|
||||
createProfileNavLinkActive = true
|
||||
} label: {
|
||||
Text("Create your profile")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
|
||||
var body: some View {
|
||||
if m.currentUser == nil {
|
||||
actionButton("Create your profile", onboarding: .step2_CreateProfile)
|
||||
} else {
|
||||
actionButton("Make a private connection", onboarding: .onboardingComplete)
|
||||
NavigationLink(isActive: $createProfileNavLinkActive) {
|
||||
createProfileDestinationView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
|
||||
private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View {
|
||||
Button {
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(onboarding)
|
||||
m.onboardingStage = onboarding
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(label).font(.title2)
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
private func actionButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
|
||||
Button {
|
||||
withAnimation {
|
||||
action()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(label).font(.title2)
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.bottom)
|
||||
private func createProfileDestinationView() -> some View {
|
||||
CreateFirstProfile()
|
||||
.navigationTitle("Create your profile")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,190 +7,209 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private struct VersionDescription {
|
||||
var version: String
|
||||
var post: URL?
|
||||
var features: [FeatureDescription]
|
||||
var features: [Feature]
|
||||
}
|
||||
|
||||
private struct FeatureDescription {
|
||||
var icon: String?
|
||||
var title: LocalizedStringKey
|
||||
var description: LocalizedStringKey?
|
||||
private enum Feature: Identifiable {
|
||||
case feature(Description)
|
||||
case view(FeatureView)
|
||||
|
||||
var id: LocalizedStringKey {
|
||||
switch self {
|
||||
case let .feature(d): d.title
|
||||
case let .view(v): v.title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Description {
|
||||
let icon: String?
|
||||
let title: LocalizedStringKey
|
||||
let description: LocalizedStringKey?
|
||||
var subfeatures: [(icon: String, description: LocalizedStringKey)] = []
|
||||
}
|
||||
|
||||
private struct FeatureView {
|
||||
let icon: String?
|
||||
let title: LocalizedStringKey
|
||||
let view: () -> any View
|
||||
}
|
||||
|
||||
private let versionDescriptions: [VersionDescription] = [
|
||||
VersionDescription(
|
||||
version: "v4.2",
|
||||
post: URL(string: "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "checkmark.shield",
|
||||
title: "Security assessment",
|
||||
description: "SimpleX Chat security was audited by Trail of Bits."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "person.2",
|
||||
title: "Group links",
|
||||
description: "Admins can create the links to join groups."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "checkmark",
|
||||
title: "Auto-accept contact requests",
|
||||
description: "With optional welcome message."
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.3",
|
||||
post: URL(string: "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "mic",
|
||||
title: "Voice messages",
|
||||
description: "Max 30 seconds, received instantly."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "trash.slash",
|
||||
title: "Irreversible message deletion",
|
||||
description: "Your contacts can allow full message deletion."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "externaldrive.connected.to.line.below",
|
||||
title: "Improved server configuration",
|
||||
description: "Add servers by scanning QR codes."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "eye.slash",
|
||||
title: "Improved privacy and security",
|
||||
description: "Hide app screen in the recent apps."
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.4",
|
||||
post: URL(string: "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "stopwatch",
|
||||
title: "Disappearing messages",
|
||||
description: "Sent messages will be deleted after set time."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "ellipsis.circle",
|
||||
title: "Live messages",
|
||||
description: "Recipients see updates as you type them."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "checkmark.shield",
|
||||
title: "Verify connection security",
|
||||
description: "Compare security codes with your contacts."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "camera",
|
||||
title: "GIFs and stickers",
|
||||
description: "Send them from gallery or custom keyboards."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "French interface",
|
||||
description: "Thanks to the users – contribute via Weblate!"
|
||||
)
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.5",
|
||||
post: URL(string: "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "person.crop.rectangle.stack",
|
||||
title: "Multiple chat profiles",
|
||||
description: "Different names, avatars and transport isolation."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "rectangle.and.pencil.and.ellipsis",
|
||||
title: "Message draft",
|
||||
description: "Preserve the last message draft, with attachments."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "network.badge.shield.half.filled",
|
||||
title: "Transport isolation",
|
||||
description: "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "lock.doc",
|
||||
title: "Private filenames",
|
||||
description: "To protect timezone, image/voice files use UTC."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "battery.25",
|
||||
title: "Reduced battery usage",
|
||||
description: "More improvements are coming soon!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Italian interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
)
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.6",
|
||||
post: URL(string: "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "lock",
|
||||
title: "Hidden chat profiles",
|
||||
description: "Protect your chat profiles with a password!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "phone.arrow.up.right",
|
||||
title: "Audio and video calls",
|
||||
description: "Fully re-implemented - work in background!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "flag",
|
||||
title: "Group moderation",
|
||||
description: "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "plus.message",
|
||||
title: "Group welcome message",
|
||||
description: "Set the message shown to new members!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "battery.50",
|
||||
title: "Further reduced battery usage",
|
||||
description: "More improvements are coming soon!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Chinese and Spanish interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.0",
|
||||
post: URL(string: "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "arrow.up.doc",
|
||||
title: "Videos and files up to 1gb",
|
||||
description: "Fast and no wait until the sender is online!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "lock",
|
||||
title: "App passcode",
|
||||
description: "Set it instead of system authentication."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Polish interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
// Also
|
||||
|
@ -200,240 +219,240 @@ private let versionDescriptions: [VersionDescription] = [
|
|||
version: "v5.1",
|
||||
post: URL(string: "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "face.smiling",
|
||||
title: "Message reactions",
|
||||
description: "Finally, we have them! 🚀"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "arrow.up.message",
|
||||
title: "Better messages",
|
||||
description: "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "lock",
|
||||
title: "Self-destruct passcode",
|
||||
description: "All data is erased when it is entered."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Japanese interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.2",
|
||||
post: URL(string: "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "checkmark",
|
||||
title: "Message delivery receipts!",
|
||||
description: "The second tick we missed! ✅"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "star",
|
||||
title: "Find chats faster",
|
||||
description: "Filter unread and favorite chats."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "exclamationmark.arrow.triangle.2.circlepath",
|
||||
title: "Keep your connections",
|
||||
description: "Fix encryption after restoring backups."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "stopwatch",
|
||||
title: "Make one message disappear",
|
||||
description: "Even when disabled in the conversation."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "gift",
|
||||
title: "A few more things",
|
||||
description: "- more stable message delivery.\n- a bit better groups.\n- and more!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.3",
|
||||
post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "desktopcomputer",
|
||||
title: "New desktop app!",
|
||||
description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "lock",
|
||||
title: "Encrypt stored files & media",
|
||||
description: "App encrypts new local files (except videos)."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "magnifyingglass",
|
||||
title: "Discover and join groups",
|
||||
description: "- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "theatermasks",
|
||||
title: "Simplified incognito mode",
|
||||
description: "Toggle incognito when connecting."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "\(4) new interface languages",
|
||||
description: "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.4",
|
||||
post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "desktopcomputer",
|
||||
title: "Link mobile and desktop apps! 🔗",
|
||||
description: "Via secure quantum resistant protocol."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "person.2",
|
||||
title: "Better groups",
|
||||
description: "Faster joining and more reliable messages."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "theatermasks",
|
||||
title: "Incognito groups",
|
||||
description: "Create a group using a random profile."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "hand.raised",
|
||||
title: "Block group members",
|
||||
description: "To hide unwanted messages."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "gift",
|
||||
title: "A few more things",
|
||||
description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.5",
|
||||
post: URL(string: "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "folder",
|
||||
title: "Private notes",
|
||||
description: "With encrypted files and media."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "link",
|
||||
title: "Paste link to connect!",
|
||||
description: "Search bar accepts invitation links."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "bubble.left.and.bubble.right",
|
||||
title: "Join group conversations",
|
||||
description: "Recent history and improved [directory bot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "battery.50",
|
||||
title: "Improved message delivery",
|
||||
description: "With reduced battery usage."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Turkish interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.6",
|
||||
post: URL(string: "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "key",
|
||||
title: "Quantum resistant encryption",
|
||||
description: "Enable in direct chats (BETA)!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "tray.and.arrow.up",
|
||||
title: "App data migration",
|
||||
description: "Migrate to another device via QR code."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "phone",
|
||||
title: "Picture-in-picture calls",
|
||||
description: "Use the app while in the call."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "hand.raised",
|
||||
title: "Safer groups",
|
||||
description: "Admins can block a member for all."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Hungarian interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.7",
|
||||
post: URL(string: "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "key",
|
||||
title: "Quantum resistant encryption",
|
||||
description: "Will be enabled in direct chats!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "arrowshape.turn.up.forward",
|
||||
title: "Forward and save messages",
|
||||
description: "Message source remains private."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "music.note",
|
||||
title: "In-call sounds",
|
||||
description: "When connecting audio and video calls."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "person.crop.square",
|
||||
title: "Shape profile images",
|
||||
description: "Square, circle, or anything in between."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Network management",
|
||||
description: "More reliable network connection."
|
||||
)
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.8",
|
||||
post: URL(string: "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "arrow.forward",
|
||||
title: "Private message routing 🚀",
|
||||
description: "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "network.badge.shield.half.filled",
|
||||
title: "Safely receive files",
|
||||
description: "Confirm files from unknown servers."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "battery.50",
|
||||
title: "Improved message delivery",
|
||||
description: "With reduced battery usage."
|
||||
)
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v6.0",
|
||||
post: URL(string: "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: nil,
|
||||
title: "New chat experience 🎉",
|
||||
description: nil,
|
||||
|
@ -444,8 +463,8 @@ private let versionDescriptions: [VersionDescription] = [
|
|||
("platter.filled.bottom.and.arrow.down.iphone", "Use the app with one hand."),
|
||||
("paintpalette", "Color chats with the new themes."),
|
||||
]
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: nil,
|
||||
title: "New media options",
|
||||
description: nil,
|
||||
|
@ -454,39 +473,39 @@ private let versionDescriptions: [VersionDescription] = [
|
|||
("play.circle", "Play from the chat list."),
|
||||
("circle.filled.pattern.diagonalline.rectangle", "Blur for better privacy.")
|
||||
]
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "arrow.forward",
|
||||
title: "Private message routing 🚀",
|
||||
description: "It protects your IP address and connections."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "network",
|
||||
title: "Better networking",
|
||||
description: "Connection and servers status."
|
||||
)
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v6.1",
|
||||
post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "checkmark.shield",
|
||||
title: "Better security ✅",
|
||||
description: "SimpleX protocols reviewed by Trail of Bits."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "video",
|
||||
title: "Better calls",
|
||||
description: "Switch audio and video during the call."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "bolt",
|
||||
title: "Better notifications",
|
||||
description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: nil,
|
||||
title: "Better user experience",
|
||||
description: nil,
|
||||
|
@ -497,9 +516,25 @@ private let versionDescriptions: [VersionDescription] = [
|
|||
("arrowshape.turn.up.right", "Forward up to 20 messages at once."),
|
||||
("flag", "Delete or moderate up to 200 messages.")
|
||||
]
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v6.2 (beta.1)",
|
||||
post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"),
|
||||
features: [
|
||||
.view(FeatureView(
|
||||
icon: nil,
|
||||
title: "Network decentralization",
|
||||
view: { NewOperatorsView() }
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "text.quote",
|
||||
title: "Improved chat navigation",
|
||||
description: "- Open chat on the first unread message.\n- Jump to quoted messages."
|
||||
)),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
private let lastVersion = versionDescriptions.last!.version
|
||||
|
@ -514,14 +549,68 @@ func shouldShowWhatsNew() -> Bool {
|
|||
return v != lastVersion
|
||||
}
|
||||
|
||||
fileprivate struct NewOperatorsView: View {
|
||||
@State private var showOperatorsSheet = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 48)
|
||||
Text("The second preset operator in the app!")
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(10)
|
||||
HStack {
|
||||
Button("Enable Flux") {
|
||||
showOperatorsSheet = true
|
||||
}
|
||||
Text("for better metadata privacy.")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showOperatorsSheet) {
|
||||
NavigationView {
|
||||
ChooseServerOperators(onboarding: false)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum WhatsNewViewSheet: Identifiable {
|
||||
case showConditions
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .showConditions: return "showConditions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WhatsNewView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State var currentVersion = versionDescriptions.count - 1
|
||||
@State var currentVersionNav = versionDescriptions.count - 1
|
||||
var viaSettings = false
|
||||
var updatedConditions: Bool
|
||||
@State private var sheetItem: WhatsNewViewSheet? = nil
|
||||
|
||||
var body: some View {
|
||||
whatsNewView()
|
||||
.sheet(item: $sheetItem) { item in
|
||||
switch item {
|
||||
case .showConditions:
|
||||
UsageConditionsView(
|
||||
currUserServers: Binding.constant([]),
|
||||
userServers: Binding.constant([])
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func whatsNewView() -> some View {
|
||||
VStack {
|
||||
TabView(selection: $currentVersion) {
|
||||
ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in
|
||||
|
@ -532,9 +621,11 @@ struct WhatsNewView: View {
|
|||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
ForEach(v.features, id: \.title) { f in
|
||||
featureDescription(f)
|
||||
.padding(.bottom, 8)
|
||||
ForEach(v.features) { f in
|
||||
switch f {
|
||||
case let .feature(d): featureDescription(d).padding(.bottom, 8)
|
||||
case let .view(v): AnyView(v.view()).padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
if let post = v.post {
|
||||
Link(destination: post) {
|
||||
|
@ -544,13 +635,20 @@ struct WhatsNewView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
if updatedConditions {
|
||||
Button("View updated conditions") {
|
||||
sheetItem = .showConditions
|
||||
}
|
||||
}
|
||||
if !viaSettings {
|
||||
Spacer()
|
||||
|
||||
Button("Ok") {
|
||||
dismiss()
|
||||
}
|
||||
.font(.title3)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
@ -568,20 +666,24 @@ struct WhatsNewView: View {
|
|||
currentVersionNav = currentVersion
|
||||
}
|
||||
}
|
||||
|
||||
private func featureDescription(_ f: FeatureDescription) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let icon = f.icon {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(minWidth: 30, alignment: .center)
|
||||
Text(f.title).font(.title3).bold()
|
||||
}
|
||||
} else {
|
||||
Text(f.title).font(.title3).bold()
|
||||
|
||||
@ViewBuilder private func featureHeader(_ icon: String?, _ title: LocalizedStringKey) -> some View {
|
||||
if let icon {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(minWidth: 30, alignment: .center)
|
||||
Text(title).font(.title3).bold()
|
||||
}
|
||||
} else {
|
||||
Text(title).font(.title3).bold()
|
||||
}
|
||||
}
|
||||
|
||||
private func featureDescription(_ f: Description) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
featureHeader(f.icon, f.title)
|
||||
if let d = f.description {
|
||||
Text(d)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
@ -636,6 +738,6 @@ struct WhatsNewView: View {
|
|||
|
||||
struct NewFeaturesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
WhatsNewView()
|
||||
WhatsNewView(updatedConditions: false)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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([])
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -12,15 +12,15 @@ import SimpleXChat
|
|||
struct ProtocolServerView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let serverProtocol: ServerProtocol
|
||||
@Binding var server: ServerCfg
|
||||
@State var serverToEdit: ServerCfg
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var server: UserServer
|
||||
@State var serverToEdit: UserServer
|
||||
var backLabel: LocalizedStringKey
|
||||
@State private var showTestFailure = false
|
||||
@State private var testing = false
|
||||
@State private var testFailure: ProtocolTestFailure?
|
||||
|
||||
var proto: String { serverProtocol.rawValue.uppercased() }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if server.preset {
|
||||
|
@ -32,9 +32,33 @@ struct ProtocolServerView: View {
|
|||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) {
|
||||
server = serverToEdit
|
||||
dismiss()
|
||||
.modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) {
|
||||
if let (serverToEditProtocol, serverToEditOperator) = serverProtocolAndOperator(serverToEdit, userServers),
|
||||
let (serverProtocol, serverOperator) = serverProtocolAndOperator(server, userServers) {
|
||||
if serverToEditProtocol != serverProtocol {
|
||||
dismiss()
|
||||
showAlert(
|
||||
NSLocalizedString("Error updating server", comment: "alert title"),
|
||||
message: NSLocalizedString("Server protocol changed.", comment: "alert title")
|
||||
)
|
||||
} else if serverToEditOperator != serverOperator {
|
||||
dismiss()
|
||||
showAlert(
|
||||
NSLocalizedString("Error updating server", comment: "alert title"),
|
||||
message: NSLocalizedString("Server operator changed.", comment: "alert title")
|
||||
)
|
||||
} else {
|
||||
server = serverToEdit
|
||||
validateServers_($userServers, $serverErrors)
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
dismiss()
|
||||
showAlert(
|
||||
NSLocalizedString("Invalid server address!", comment: "alert title"),
|
||||
message: NSLocalizedString("Check server address and try again.", comment: "alert title")
|
||||
)
|
||||
}
|
||||
})
|
||||
.alert(isPresented: $showTestFailure) {
|
||||
Alert(
|
||||
|
@ -62,7 +86,7 @@ struct ProtocolServerView: View {
|
|||
private func customServer() -> some View {
|
||||
VStack {
|
||||
let serverAddress = parseServerAddress(serverToEdit.server)
|
||||
let valid = serverAddress?.valid == true && serverAddress?.serverProtocol == serverProtocol
|
||||
let valid = serverAddress?.valid == true
|
||||
List {
|
||||
Section {
|
||||
TextEditor(text: $serverToEdit.server)
|
||||
|
@ -112,10 +136,7 @@ struct ProtocolServerView: View {
|
|||
Spacer()
|
||||
showTestStatus(server: serverToEdit)
|
||||
}
|
||||
let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset
|
||||
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
|
||||
.disabled(useForNewDisabled)
|
||||
.foregroundColor(useForNewDisabled ? theme.colors.secondary : theme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -142,7 +163,7 @@ struct BackButton: ViewModifier {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func showTestStatus(server: ServerCfg) -> some View {
|
||||
@ViewBuilder func showTestStatus(server: UserServer) -> some View {
|
||||
switch server.tested {
|
||||
case .some(true):
|
||||
Image(systemName: "checkmark")
|
||||
|
@ -155,7 +176,7 @@ struct BackButton: ViewModifier {
|
|||
}
|
||||
}
|
||||
|
||||
func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailure? {
|
||||
func testServerConnection(server: Binding<UserServer>) async -> ProtocolTestFailure? {
|
||||
do {
|
||||
let r = try await testProtoServer(server: server.wrappedValue.server)
|
||||
switch r {
|
||||
|
@ -178,9 +199,11 @@ func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailu
|
|||
struct ProtocolServerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ProtocolServerView(
|
||||
serverProtocol: .smp,
|
||||
server: Binding.constant(ServerCfg.sampleData.custom),
|
||||
serverToEdit: ServerCfg.sampleData.custom
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([]),
|
||||
server: Binding.constant(UserServer.sampleData.custom),
|
||||
serverToEdit: UserServer.sampleData.custom,
|
||||
backLabel: "Your SMP servers"
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -12,8 +12,8 @@ import CodeScanner
|
|||
|
||||
struct ScanProtocolServer: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Binding var servers: [ServerCfg]
|
||||
@State private var showAddressError = false
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
|
@ -28,23 +28,14 @@ struct ScanProtocolServer: View {
|
|||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(isPresented: $showAddressError) {
|
||||
Alert(
|
||||
title: Text("Invalid server address!"),
|
||||
message: Text("Check server address and try again.")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
if parseServerAddress(r.string) != nil {
|
||||
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false))
|
||||
dismiss()
|
||||
} else {
|
||||
showAddressError = true
|
||||
}
|
||||
var server: UserServer = .empty
|
||||
server.server = r.string
|
||||
addServer(server, $userServers, $serverErrors, dismiss)
|
||||
case let .failure(e):
|
||||
logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)")
|
||||
dismiss()
|
||||
|
@ -54,6 +45,9 @@ struct ScanProtocolServer: View {
|
|||
|
||||
struct ScanProtocolServer_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ScanProtocolServer(servers: Binding.constant([]))
|
||||
ScanProtocolServer(
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([])
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -50,6 +50,7 @@ let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
|
|||
let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
|
||||
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
|
||||
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
|
||||
let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
|
||||
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
|
||||
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
||||
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
|
||||
|
@ -107,6 +108,7 @@ let appDefaults: [String: Any] = [
|
|||
DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness,
|
||||
DEFAULT_CHAT_ITEM_TAIL: true,
|
||||
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
|
||||
DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false,
|
||||
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
|
||||
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
|
||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
|
||||
|
@ -135,6 +137,7 @@ let appDefaults: [String: Any] = [
|
|||
let hintDefaults = [
|
||||
DEFAULT_LA_NOTICE_SHOWN,
|
||||
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
|
||||
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
|
||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
|
||||
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
|
||||
DEFAULT_SHOW_MUTE_PROFILE_ALERT,
|
||||
|
@ -356,7 +359,7 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
NavigationLink {
|
||||
WhatsNewView(viaSettings: true)
|
||||
WhatsNewView(viaSettings: true, updatedConditions: false)
|
||||
.modifier(ThemedBackground())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} label: {
|
||||
|
|
|
@ -9,15 +9,47 @@
|
|||
import SwiftUI
|
||||
|
||||
struct UserAddressLearnMore: View {
|
||||
@State var showCreateAddressButton = false
|
||||
@State private var createAddressLinkActive = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("You can share your address as a link or QR code - anybody can connect to you.")
|
||||
Text("You won't lose your contacts if you later delete your address.")
|
||||
Text("When people request to connect, you can accept or reject it.")
|
||||
Text("Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).")
|
||||
VStack {
|
||||
List {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("You can share your address as a link or QR code - anybody can connect to you.")
|
||||
Text("You won't lose your contacts if you later delete your address.")
|
||||
Text("When people request to connect, you can accept or reject it.")
|
||||
Text("Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
if showCreateAddressButton {
|
||||
addressCreationButton()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addressCreationButton() -> some View {
|
||||
ZStack {
|
||||
Button {
|
||||
createAddressLinkActive = true
|
||||
} label: {
|
||||
Text("Create SimpleX address")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle())
|
||||
|
||||
NavigationLink(isActive: $createAddressLinkActive) {
|
||||
UserAddressView(autoCreate: true)
|
||||
.navigationTitle("SimpleX address")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ struct UserAddressView: View {
|
|||
@EnvironmentObject private var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State var shareViaProfile = false
|
||||
@State var autoCreate = false
|
||||
@State private var aas = AutoAcceptState()
|
||||
@State private var savedAAS = AutoAcceptState()
|
||||
@State private var ignoreShareViaProfileChange = false
|
||||
|
@ -67,6 +68,11 @@ struct UserAddressView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if chatModel.userAddress == nil, autoCreate {
|
||||
createAddress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Namespace private var bottomID
|
||||
|
@ -212,26 +218,30 @@ struct UserAddressView: View {
|
|||
|
||||
private func createAddressButton() -> some View {
|
||||
Button {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let connReqContact = try await apiCreateUserAddress()
|
||||
DispatchQueue.main.async {
|
||||
chatModel.userAddress = UserContactLink(connReqContact: connReqContact)
|
||||
alert = .shareOnCreate
|
||||
progressIndicator = false
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error creating address")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
await MainActor.run { progressIndicator = false }
|
||||
}
|
||||
}
|
||||
createAddress()
|
||||
} label: {
|
||||
Label("Create SimpleX address", systemImage: "qrcode")
|
||||
}
|
||||
}
|
||||
|
||||
private func createAddress() {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let connReqContact = try await apiCreateUserAddress()
|
||||
DispatchQueue.main.async {
|
||||
chatModel.userAddress = UserContactLink(connReqContact: connReqContact)
|
||||
alert = .shareOnCreate
|
||||
progressIndicator = false
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error creating address")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
await MainActor.run { progressIndicator = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteAddressButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
|
|
|
@ -144,20 +144,22 @@
|
|||
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
||||
640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */; };
|
||||
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; };
|
||||
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; };
|
||||
6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; };
|
||||
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; };
|
||||
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
|
||||
642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642BA82C2CE50495005E9412 /* NewServerView.swift */; };
|
||||
642BA8332CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */; };
|
||||
642BA8342CEB3D4B005E9412 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82F2CEB3D4B005E9412 /* libffi.a */; };
|
||||
642BA8352CEB3D4B005E9412 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8302CEB3D4B005E9412 /* libgmp.a */; };
|
||||
642BA8362CEB3D4B005E9412 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8312CEB3D4B005E9412 /* libgmpxx.a */; };
|
||||
642BA8372CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */; };
|
||||
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
|
||||
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B402CCBEB080083A2CF /* libgmpxx.a */; };
|
||||
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */; };
|
||||
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B422CCBEB080083A2CF /* libffi.a */; };
|
||||
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */; };
|
||||
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B442CCBEB080083A2CF /* libgmp.a */; };
|
||||
643B3B4E2CCFD6400083A2CF /* OperatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */; };
|
||||
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
|
||||
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
|
||||
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; };
|
||||
6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; };
|
||||
64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */; };
|
||||
64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DCB29FFE3E800E3D48D /* MailView.swift */; };
|
||||
6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; };
|
||||
644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; };
|
||||
|
@ -200,7 +202,9 @@
|
|||
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
|
||||
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
|
||||
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
|
||||
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; };
|
||||
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
|
||||
B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */; };
|
||||
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; };
|
||||
CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; };
|
||||
CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; };
|
||||
|
@ -436,7 +440,7 @@
|
|||
5CB634AC29E46CF70066AD6B /* LocalAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthView.swift; sourceTree = "<group>"; };
|
||||
5CB634AE29E4BB7D0066AD6B /* SetAppPasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAppPasscodeView.swift; sourceTree = "<group>"; };
|
||||
5CB634B029E5EFEA0066AD6B /* PasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasscodeView.swift; sourceTree = "<group>"; };
|
||||
5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; wrapsLines = 0; };
|
||||
5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = "<group>"; };
|
||||
5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListNavLink.swift; sourceTree = "<group>"; };
|
||||
5CBD285529565CAE00EC2CF4 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
|
@ -487,20 +491,22 @@
|
|||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
|
||||
640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatMenuButton.swift; sourceTree = "<group>"; };
|
||||
640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = "<group>"; };
|
||||
640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = "<group>"; };
|
||||
6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = "<group>"; };
|
||||
6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextInvitingContactMemberView.swift; sourceTree = "<group>"; };
|
||||
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
|
||||
642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = "<group>"; };
|
||||
642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a"; sourceTree = "<group>"; };
|
||||
642BA82F2CEB3D4B005E9412 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
642BA8302CEB3D4B005E9412 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
642BA8312CEB3D4B005E9412 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
|
||||
643B3B402CCBEB080083A2CF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmpxx.a; path = Libraries/libgmpxx.a; sourceTree = "<group>"; };
|
||||
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
643B3B422CCBEB080083A2CF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libffi.a; path = Libraries/libffi.a; sourceTree = "<group>"; };
|
||||
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a"; path = "Libraries/libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a"; sourceTree = "<group>"; };
|
||||
643B3B442CCBEB080083A2CF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmp.a; path = Libraries/libgmp.a; sourceTree = "<group>"; };
|
||||
643B3B4D2CCFD6400083A2CF /* OperatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorView.swift; sourceTree = "<group>"; };
|
||||
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
|
||||
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
|
||||
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
|
||||
6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = "<group>"; };
|
||||
64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
|
||||
64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = "<group>"; };
|
||||
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = "<group>"; };
|
||||
644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = "<group>"; };
|
||||
|
@ -544,7 +550,9 @@
|
|||
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
|
||||
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
|
||||
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
|
||||
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
|
||||
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
|
||||
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = "<group>"; };
|
||||
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
|
||||
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
|
||||
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; };
|
||||
|
@ -657,14 +665,14 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */,
|
||||
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */,
|
||||
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
|
||||
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a in Frameworks */,
|
||||
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a in Frameworks */,
|
||||
642BA8342CEB3D4B005E9412 /* libffi.a in Frameworks */,
|
||||
642BA8352CEB3D4B005E9412 /* libgmp.a in Frameworks */,
|
||||
642BA8372CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a in Frameworks */,
|
||||
642BA8332CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a in Frameworks */,
|
||||
642BA8362CEB3D4B005E9412 /* libgmpxx.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -741,6 +749,11 @@
|
|||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
642BA82F2CEB3D4B005E9412 /* libffi.a */,
|
||||
642BA8302CEB3D4B005E9412 /* libgmp.a */,
|
||||
642BA8312CEB3D4B005E9412 /* libgmpxx.a */,
|
||||
642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */,
|
||||
642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
|
@ -812,11 +825,6 @@
|
|||
5CC2C0FA2809BF11000C35E3 /* Localizable.strings */,
|
||||
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */,
|
||||
5C764E5C279C70B7000C6508 /* Libraries */,
|
||||
643B3B422CCBEB080083A2CF /* libffi.a */,
|
||||
643B3B442CCBEB080083A2CF /* libgmp.a */,
|
||||
643B3B402CCBEB080083A2CF /* libgmpxx.a */,
|
||||
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */,
|
||||
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */,
|
||||
5CA059C2279559F40002BEB4 /* Shared */,
|
||||
5CDCAD462818589900503DA2 /* SimpleX NSE */,
|
||||
CEE723A82C3BD3D70009AE93 /* SimpleX SE */,
|
||||
|
@ -875,13 +883,15 @@
|
|||
5CB0BA8C282711BC00B3292C /* Onboarding */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */,
|
||||
5CB0BA8D2827126500B3292C /* OnboardingView.swift */,
|
||||
5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */,
|
||||
5CB0BA992827FD8800B3292C /* HowItWorks.swift */,
|
||||
5CB0BA91282713FD00B3292C /* CreateProfile.swift */,
|
||||
64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */,
|
||||
5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */,
|
||||
5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */,
|
||||
640743602CD360E600158442 /* ChooseServerOperators.swift */,
|
||||
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */,
|
||||
);
|
||||
path = Onboarding;
|
||||
sourceTree = "<group>";
|
||||
|
@ -912,10 +922,9 @@
|
|||
5CB924DF27A8678B00ACCCDD /* UserSettings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
643B3B4C2CCFD34B0083A2CF /* NetworkAndServers */,
|
||||
5CB924D627A8563F00ACCCDD /* SettingsView.swift */,
|
||||
5CB346E62868D76D001FD2EF /* NotificationsView.swift */,
|
||||
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
|
||||
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
|
||||
5CADE79929211BB900072E13 /* PreferencesView.swift */,
|
||||
5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */,
|
||||
5C05DF522840AA1D00C683F9 /* CallSettings.swift */,
|
||||
|
@ -923,9 +932,6 @@
|
|||
5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */,
|
||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
|
||||
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */,
|
||||
5C93292E29239A170090FFF9 /* ProtocolServersView.swift */,
|
||||
5C93293029239BED0090FFF9 /* ProtocolServerView.swift */,
|
||||
5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */,
|
||||
5CB2084E28DA4B4800D024EC /* RTCServers.swift */,
|
||||
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */,
|
||||
18415845648CA4F5A8BCA272 /* UserProfilesView.swift */,
|
||||
|
@ -1056,6 +1062,20 @@
|
|||
path = Database;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
643B3B4C2CCFD34B0083A2CF /* NetworkAndServers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */,
|
||||
642BA82C2CE50495005E9412 /* NewServerView.swift */,
|
||||
5C93293029239BED0090FFF9 /* ProtocolServerView.swift */,
|
||||
5C93292E29239A170090FFF9 /* ProtocolServersView.swift */,
|
||||
643B3B4D2CCFD6400083A2CF /* OperatorView.swift */,
|
||||
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
|
||||
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
|
||||
);
|
||||
path = NetworkAndServers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6440CA01288AEC770062C672 /* Group */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1375,10 +1395,12 @@
|
|||
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */,
|
||||
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */,
|
||||
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */,
|
||||
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */,
|
||||
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
|
||||
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
|
||||
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */,
|
||||
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
|
||||
B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */,
|
||||
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
|
||||
E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */,
|
||||
5C65DAF929D0CC20003CEE45 /* DeveloperView.swift in Sources */,
|
||||
|
@ -1405,12 +1427,12 @@
|
|||
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */,
|
||||
5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */,
|
||||
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */,
|
||||
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */,
|
||||
5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */,
|
||||
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */,
|
||||
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */,
|
||||
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
|
||||
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */,
|
||||
64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */,
|
||||
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
|
||||
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
|
||||
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */,
|
||||
|
@ -1528,7 +1550,9 @@
|
|||
5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */,
|
||||
18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */,
|
||||
18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */,
|
||||
642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */,
|
||||
184158C131FDB829D8A117EA /* VideoPlayerView.swift in Sources */,
|
||||
643B3B4E2CCFD6400083A2CF /* OperatorView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
|
@ -72,9 +72,15 @@ public enum ChatCommand {
|
|||
case apiGetGroupLink(groupId: Int64)
|
||||
case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64)
|
||||
case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent)
|
||||
case apiGetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol)
|
||||
case apiSetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol, servers: [ServerCfg])
|
||||
case apiTestProtoServer(userId: Int64, server: String)
|
||||
case apiGetServerOperators
|
||||
case apiSetServerOperators(operators: [ServerOperator])
|
||||
case apiGetUserServers(userId: Int64)
|
||||
case apiSetUserServers(userId: Int64, userServers: [UserOperatorServers])
|
||||
case apiValidateServers(userId: Int64, userServers: [UserOperatorServers])
|
||||
case apiGetUsageConditions
|
||||
case apiSetConditionsNotified(conditionsId: Int64)
|
||||
case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64])
|
||||
case apiSetChatItemTTL(userId: Int64, seconds: Int64?)
|
||||
case apiGetChatItemTTL(userId: Int64)
|
||||
case apiSetNetworkConfig(networkConfig: NetCfg)
|
||||
|
@ -231,9 +237,15 @@ public enum ChatCommand {
|
|||
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
|
||||
case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)"
|
||||
case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)"
|
||||
case let .apiGetUserProtoServers(userId, serverProtocol): return "/_servers \(userId) \(serverProtocol)"
|
||||
case let .apiSetUserProtoServers(userId, serverProtocol, servers): return "/_servers \(userId) \(serverProtocol) \(protoServersStr(servers))"
|
||||
case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)"
|
||||
case .apiGetServerOperators: return "/_operators"
|
||||
case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))"
|
||||
case let .apiGetUserServers(userId): return "/_servers \(userId)"
|
||||
case let .apiSetUserServers(userId, userServers): return "/_servers \(userId) \(encodeJSON(userServers))"
|
||||
case let .apiValidateServers(userId, userServers): return "/_validate_servers \(userId) \(encodeJSON(userServers))"
|
||||
case .apiGetUsageConditions: return "/_conditions"
|
||||
case let .apiSetConditionsNotified(conditionsId): return "/_conditions_notified \(conditionsId)"
|
||||
case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))"
|
||||
case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
|
||||
case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
|
||||
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
|
||||
|
@ -386,9 +398,15 @@ public enum ChatCommand {
|
|||
case .apiGetGroupLink: return "apiGetGroupLink"
|
||||
case .apiCreateMemberContact: return "apiCreateMemberContact"
|
||||
case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation"
|
||||
case .apiGetUserProtoServers: return "apiGetUserProtoServers"
|
||||
case .apiSetUserProtoServers: return "apiSetUserProtoServers"
|
||||
case .apiTestProtoServer: return "apiTestProtoServer"
|
||||
case .apiGetServerOperators: return "apiGetServerOperators"
|
||||
case .apiSetServerOperators: return "apiSetServerOperators"
|
||||
case .apiGetUserServers: return "apiGetUserServers"
|
||||
case .apiSetUserServers: return "apiSetUserServers"
|
||||
case .apiValidateServers: return "apiValidateServers"
|
||||
case .apiGetUsageConditions: return "apiGetUsageConditions"
|
||||
case .apiSetConditionsNotified: return "apiSetConditionsNotified"
|
||||
case .apiAcceptConditions: return "apiAcceptConditions"
|
||||
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
|
||||
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
|
||||
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
|
||||
|
@ -475,10 +493,6 @@ public enum ChatCommand {
|
|||
func joinedIds(_ ids: [Int64]) -> String {
|
||||
ids.map { "\($0)" }.joined(separator: ",")
|
||||
}
|
||||
|
||||
func protoServersStr(_ servers: [ServerCfg]) -> String {
|
||||
encodeJSON(ProtoServersConfig(servers: servers))
|
||||
}
|
||||
|
||||
func chatItemTTLStr(seconds: Int64?) -> String {
|
||||
if let seconds = seconds {
|
||||
|
@ -548,8 +562,11 @@ public enum ChatResponse: Decodable, Error {
|
|||
case apiChats(user: UserRef, chats: [ChatData])
|
||||
case apiChat(user: UserRef, chat: ChatData)
|
||||
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
|
||||
case userProtoServers(user: UserRef, servers: UserProtoServers)
|
||||
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
|
||||
case serverOperatorConditions(conditions: ServerOperatorConditions)
|
||||
case userServers(user: UserRef, userServers: [UserOperatorServers])
|
||||
case userServersValidation(user: UserRef, serverErrors: [UserServersError])
|
||||
case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?)
|
||||
case chatItemTTL(user: UserRef, chatItemTTL: Int64?)
|
||||
case networkConfig(networkConfig: NetCfg)
|
||||
case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?)
|
||||
|
@ -721,8 +738,11 @@ public enum ChatResponse: Decodable, Error {
|
|||
case .apiChats: return "apiChats"
|
||||
case .apiChat: return "apiChat"
|
||||
case .chatItemInfo: return "chatItemInfo"
|
||||
case .userProtoServers: return "userProtoServers"
|
||||
case .serverTestResult: return "serverTestResult"
|
||||
case .serverOperatorConditions: return "serverOperators"
|
||||
case .userServers: return "userServers"
|
||||
case .userServersValidation: return "userServersValidation"
|
||||
case .usageConditions: return "usageConditions"
|
||||
case .chatItemTTL: return "chatItemTTL"
|
||||
case .networkConfig: return "networkConfig"
|
||||
case .contactInfo: return "contactInfo"
|
||||
|
@ -890,8 +910,11 @@ public enum ChatResponse: Decodable, Error {
|
|||
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
|
||||
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
|
||||
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
|
||||
case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))")
|
||||
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
|
||||
case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))"
|
||||
case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))")
|
||||
case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))")
|
||||
case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))"
|
||||
case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL))
|
||||
case let .networkConfig(networkConfig): return String(describing: networkConfig)
|
||||
case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))")
|
||||
|
@ -1175,86 +1198,428 @@ public struct DBEncryptionConfig: Codable {
|
|||
public var newKey: String
|
||||
}
|
||||
|
||||
struct SMPServersConfig: Encodable {
|
||||
var smpServers: [ServerCfg]
|
||||
}
|
||||
|
||||
public enum ServerProtocol: String, Decodable {
|
||||
case smp
|
||||
case xftp
|
||||
}
|
||||
|
||||
public struct ProtoServersConfig: Codable {
|
||||
public var servers: [ServerCfg]
|
||||
public enum OperatorTag: String, Codable {
|
||||
case simplex = "simplex"
|
||||
case flux = "flux"
|
||||
case xyz = "xyz"
|
||||
case demo = "demo"
|
||||
}
|
||||
|
||||
public struct UserProtoServers: Decodable {
|
||||
public var serverProtocol: ServerProtocol
|
||||
public var protoServers: [ServerCfg]
|
||||
public var presetServers: [ServerCfg]
|
||||
public struct ServerOperatorInfo: Decodable {
|
||||
public var description: [String]
|
||||
public var website: String
|
||||
public var logo: String
|
||||
public var largeLogo: String
|
||||
public var logoDarkMode: String
|
||||
public var largeLogoDarkMode: String
|
||||
}
|
||||
|
||||
public struct ServerCfg: Identifiable, Equatable, Codable, Hashable {
|
||||
public let operatorsInfo: Dictionary<OperatorTag, ServerOperatorInfo> = [
|
||||
.simplex: ServerOperatorInfo(
|
||||
description: [
|
||||
"SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.",
|
||||
"SimpleX Chat Ltd develops the communication software for SimpleX network."
|
||||
],
|
||||
website: "https://simplex.chat",
|
||||
logo: "decentralized",
|
||||
largeLogo: "logo",
|
||||
logoDarkMode: "decentralized-light",
|
||||
largeLogoDarkMode: "logo-light"
|
||||
),
|
||||
.flux: ServerOperatorInfo(
|
||||
description: [
|
||||
"Flux is the largest decentralized cloud infrastructure, leveraging a global network of user-operated computational nodes.",
|
||||
"Flux offers a powerful, scalable, and affordable platform designed to support individuals, businesses, and cutting-edge technologies like AI. With high uptime and worldwide distribution, Flux ensures reliable, accessible cloud computing for all."
|
||||
],
|
||||
website: "https://runonflux.com",
|
||||
logo: "flux_logo_symbol",
|
||||
largeLogo: "flux_logo",
|
||||
logoDarkMode: "flux_logo_symbol",
|
||||
largeLogoDarkMode: "flux_logo-light"
|
||||
),
|
||||
.xyz: ServerOperatorInfo(
|
||||
description: ["XYZ servers"],
|
||||
website: "XYZ website",
|
||||
logo: "shield",
|
||||
largeLogo: "logo",
|
||||
logoDarkMode: "shield",
|
||||
largeLogoDarkMode: "logo-light"
|
||||
),
|
||||
.demo: ServerOperatorInfo(
|
||||
description: ["Demo operator"],
|
||||
website: "Demo website",
|
||||
logo: "decentralized",
|
||||
largeLogo: "logo",
|
||||
logoDarkMode: "decentralized-light",
|
||||
largeLogoDarkMode: "logo-light"
|
||||
)
|
||||
]
|
||||
|
||||
public struct UsageConditions: Decodable {
|
||||
public var conditionsId: Int64
|
||||
public var conditionsCommit: String
|
||||
public var notifiedAt: Date?
|
||||
public var createdAt: Date
|
||||
|
||||
public static var sampleData = UsageConditions(
|
||||
conditionsId: 1,
|
||||
conditionsCommit: "11a44dc1fd461a93079f897048b46998db55da5c",
|
||||
notifiedAt: nil,
|
||||
createdAt: Date.now
|
||||
)
|
||||
}
|
||||
|
||||
public enum UsageConditionsAction: Decodable {
|
||||
case review(operators: [ServerOperator], deadline: Date?, showNotice: Bool)
|
||||
case accepted(operators: [ServerOperator])
|
||||
|
||||
public var showNotice: Bool {
|
||||
switch self {
|
||||
case let .review(_, _, showNotice): showNotice
|
||||
case .accepted: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ServerOperatorConditions: Decodable {
|
||||
public var serverOperators: [ServerOperator]
|
||||
public var currentConditions: UsageConditions
|
||||
public var conditionsAction: UsageConditionsAction?
|
||||
|
||||
public static var empty = ServerOperatorConditions(
|
||||
serverOperators: [],
|
||||
currentConditions: UsageConditions(conditionsId: 0, conditionsCommit: "empty", notifiedAt: nil, createdAt: .now),
|
||||
conditionsAction: nil
|
||||
)
|
||||
}
|
||||
|
||||
public enum ConditionsAcceptance: Equatable, Codable, Hashable {
|
||||
case accepted(acceptedAt: Date?)
|
||||
// If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator.
|
||||
// No deadline indicates it's required to accept conditions for the operator to start using it.
|
||||
case required(deadline: Date?)
|
||||
|
||||
public var conditionsAccepted: Bool {
|
||||
switch self {
|
||||
case .accepted: true
|
||||
case .required: false
|
||||
}
|
||||
}
|
||||
|
||||
public var usageAllowed: Bool {
|
||||
switch self {
|
||||
case .accepted: true
|
||||
case let .required(deadline): deadline != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ServerOperator: Identifiable, Equatable, Codable {
|
||||
public var operatorId: Int64
|
||||
public var operatorTag: OperatorTag?
|
||||
public var tradeName: String
|
||||
public var legalName: String?
|
||||
public var serverDomains: [String]
|
||||
public var conditionsAcceptance: ConditionsAcceptance
|
||||
public var enabled: Bool
|
||||
public var smpRoles: ServerRoles
|
||||
public var xftpRoles: ServerRoles
|
||||
|
||||
public var id: Int64 { operatorId }
|
||||
|
||||
public static func == (l: ServerOperator, r: ServerOperator) -> Bool {
|
||||
l.operatorId == r.operatorId && l.operatorTag == r.operatorTag && l.tradeName == r.tradeName && l.legalName == r.legalName &&
|
||||
l.serverDomains == r.serverDomains && l.conditionsAcceptance == r.conditionsAcceptance && l.enabled == r.enabled &&
|
||||
l.smpRoles == r.smpRoles && l.xftpRoles == r.xftpRoles
|
||||
}
|
||||
|
||||
public var legalName_: String {
|
||||
legalName ?? tradeName
|
||||
}
|
||||
|
||||
public var info: ServerOperatorInfo {
|
||||
return if let operatorTag = operatorTag {
|
||||
operatorsInfo[operatorTag] ?? ServerOperator.dummyOperatorInfo
|
||||
} else {
|
||||
ServerOperator.dummyOperatorInfo
|
||||
}
|
||||
}
|
||||
|
||||
public static let dummyOperatorInfo = ServerOperatorInfo(
|
||||
description: ["Default"],
|
||||
website: "Default",
|
||||
logo: "decentralized",
|
||||
largeLogo: "logo",
|
||||
logoDarkMode: "decentralized-light",
|
||||
largeLogoDarkMode: "logo-light"
|
||||
)
|
||||
|
||||
public func logo(_ colorScheme: ColorScheme) -> String {
|
||||
colorScheme == .light ? info.logo : info.logoDarkMode
|
||||
}
|
||||
|
||||
public func largeLogo(_ colorScheme: ColorScheme) -> String {
|
||||
colorScheme == .light ? info.largeLogo : info.largeLogoDarkMode
|
||||
}
|
||||
|
||||
public static var sampleData1 = ServerOperator(
|
||||
operatorId: 1,
|
||||
operatorTag: .simplex,
|
||||
tradeName: "SimpleX Chat",
|
||||
legalName: "SimpleX Chat Ltd",
|
||||
serverDomains: ["simplex.im"],
|
||||
conditionsAcceptance: .accepted(acceptedAt: nil),
|
||||
enabled: true,
|
||||
smpRoles: ServerRoles(storage: true, proxy: true),
|
||||
xftpRoles: ServerRoles(storage: true, proxy: true)
|
||||
)
|
||||
|
||||
public static var sampleData2 = ServerOperator(
|
||||
operatorId: 2,
|
||||
operatorTag: .xyz,
|
||||
tradeName: "XYZ",
|
||||
legalName: nil,
|
||||
serverDomains: ["xyz.com"],
|
||||
conditionsAcceptance: .required(deadline: nil),
|
||||
enabled: false,
|
||||
smpRoles: ServerRoles(storage: false, proxy: true),
|
||||
xftpRoles: ServerRoles(storage: false, proxy: true)
|
||||
)
|
||||
|
||||
public static var sampleData3 = ServerOperator(
|
||||
operatorId: 3,
|
||||
operatorTag: .demo,
|
||||
tradeName: "Demo",
|
||||
legalName: nil,
|
||||
serverDomains: ["demo.com"],
|
||||
conditionsAcceptance: .required(deadline: nil),
|
||||
enabled: false,
|
||||
smpRoles: ServerRoles(storage: true, proxy: false),
|
||||
xftpRoles: ServerRoles(storage: true, proxy: false)
|
||||
)
|
||||
}
|
||||
|
||||
public struct ServerRoles: Equatable, Codable {
|
||||
public var storage: Bool
|
||||
public var proxy: Bool
|
||||
}
|
||||
|
||||
public struct UserOperatorServers: Identifiable, Equatable, Codable {
|
||||
public var `operator`: ServerOperator?
|
||||
public var smpServers: [UserServer]
|
||||
public var xftpServers: [UserServer]
|
||||
|
||||
public var id: String {
|
||||
if let op = self.operator {
|
||||
"\(op.operatorId)"
|
||||
} else {
|
||||
"nil operator"
|
||||
}
|
||||
}
|
||||
|
||||
public var operator_: ServerOperator {
|
||||
get {
|
||||
self.operator ?? ServerOperator(
|
||||
operatorId: 0,
|
||||
operatorTag: nil,
|
||||
tradeName: "",
|
||||
legalName: "",
|
||||
serverDomains: [],
|
||||
conditionsAcceptance: .accepted(acceptedAt: nil),
|
||||
enabled: false,
|
||||
smpRoles: ServerRoles(storage: true, proxy: true),
|
||||
xftpRoles: ServerRoles(storage: true, proxy: true)
|
||||
)
|
||||
}
|
||||
set { `operator` = newValue }
|
||||
}
|
||||
|
||||
public static var sampleData1 = UserOperatorServers(
|
||||
operator: ServerOperator.sampleData1,
|
||||
smpServers: [UserServer.sampleData.preset],
|
||||
xftpServers: [UserServer.sampleData.xftpPreset]
|
||||
)
|
||||
|
||||
public static var sampleDataNilOperator = UserOperatorServers(
|
||||
operator: nil,
|
||||
smpServers: [UserServer.sampleData.preset],
|
||||
xftpServers: [UserServer.sampleData.xftpPreset]
|
||||
)
|
||||
}
|
||||
|
||||
public enum UserServersError: Decodable {
|
||||
case noServers(protocol: ServerProtocol, user: UserRef?)
|
||||
case storageMissing(protocol: ServerProtocol, user: UserRef?)
|
||||
case proxyMissing(protocol: ServerProtocol, user: UserRef?)
|
||||
case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String)
|
||||
|
||||
public var globalError: String? {
|
||||
switch self {
|
||||
case let .noServers(`protocol`, _):
|
||||
switch `protocol` {
|
||||
case .smp: return globalSMPError
|
||||
case .xftp: return globalXFTPError
|
||||
}
|
||||
case let .storageMissing(`protocol`, _):
|
||||
switch `protocol` {
|
||||
case .smp: return globalSMPError
|
||||
case .xftp: return globalXFTPError
|
||||
}
|
||||
case let .proxyMissing(`protocol`, _):
|
||||
switch `protocol` {
|
||||
case .smp: return globalSMPError
|
||||
case .xftp: return globalXFTPError
|
||||
}
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var globalSMPError: String? {
|
||||
switch self {
|
||||
case let .noServers(.smp, user):
|
||||
let text = NSLocalizedString("No message servers.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .storageMissing(.smp, user):
|
||||
let text = NSLocalizedString("No servers to receive messages.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .proxyMissing(.smp, user):
|
||||
let text = NSLocalizedString("No servers for private message routing.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var globalXFTPError: String? {
|
||||
switch self {
|
||||
case let .noServers(.xftp, user):
|
||||
let text = NSLocalizedString("No media & file servers.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .storageMissing(.xftp, user):
|
||||
let text = NSLocalizedString("No servers to send files.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .proxyMissing(.xftp, user):
|
||||
let text = NSLocalizedString("No servers to receive files.", comment: "servers error")
|
||||
if let user = user {
|
||||
return userStr(user) + " " + text
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func userStr(_ user: UserRef) -> String {
|
||||
String.localizedStringWithFormat(NSLocalizedString("For chat profile %@:", comment: "servers error"), user.localDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
public struct UserServer: Identifiable, Equatable, Codable, Hashable {
|
||||
public var serverId: Int64?
|
||||
public var server: String
|
||||
public var preset: Bool
|
||||
public var tested: Bool?
|
||||
public var enabled: Bool
|
||||
public var deleted: Bool
|
||||
var createdAt = Date()
|
||||
// public var sendEnabled: Bool // can we potentially want to prevent sending on the servers we use to receive?
|
||||
// Even if we don't see the use case, it's probably better to allow it in the model
|
||||
// In any case, "trusted/known" servers are out of scope of this change
|
||||
|
||||
public init(server: String, preset: Bool, tested: Bool?, enabled: Bool) {
|
||||
public init(serverId: Int64?, server: String, preset: Bool, tested: Bool?, enabled: Bool, deleted: Bool) {
|
||||
self.serverId = serverId
|
||||
self.server = server
|
||||
self.preset = preset
|
||||
self.tested = tested
|
||||
self.enabled = enabled
|
||||
self.deleted = deleted
|
||||
}
|
||||
|
||||
public static func == (l: ServerCfg, r: ServerCfg) -> Bool {
|
||||
l.server == r.server && l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled
|
||||
public static func == (l: UserServer, r: UserServer) -> Bool {
|
||||
l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested &&
|
||||
l.enabled == r.enabled && l.deleted == r.deleted
|
||||
}
|
||||
|
||||
public var id: String { "\(server) \(createdAt)" }
|
||||
|
||||
public static var empty = ServerCfg(server: "", preset: false, tested: nil, enabled: false)
|
||||
public static var empty = UserServer(serverId: nil, server: "", preset: false, tested: nil, enabled: false, deleted: false)
|
||||
|
||||
public var isEmpty: Bool {
|
||||
server.trimmingCharacters(in: .whitespaces) == ""
|
||||
}
|
||||
|
||||
public struct SampleData {
|
||||
public var preset: ServerCfg
|
||||
public var custom: ServerCfg
|
||||
public var untested: ServerCfg
|
||||
public var preset: UserServer
|
||||
public var custom: UserServer
|
||||
public var untested: UserServer
|
||||
public var xftpPreset: UserServer
|
||||
}
|
||||
|
||||
public static var sampleData = SampleData(
|
||||
preset: ServerCfg(
|
||||
preset: UserServer(
|
||||
serverId: 1,
|
||||
server: "smp://abcd@smp8.simplex.im",
|
||||
preset: true,
|
||||
tested: true,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
deleted: false
|
||||
),
|
||||
custom: ServerCfg(
|
||||
custom: UserServer(
|
||||
serverId: 2,
|
||||
server: "smp://abcd@smp9.simplex.im",
|
||||
preset: false,
|
||||
tested: false,
|
||||
enabled: false
|
||||
enabled: false,
|
||||
deleted: false
|
||||
),
|
||||
untested: ServerCfg(
|
||||
untested: UserServer(
|
||||
serverId: 3,
|
||||
server: "smp://abcd@smp10.simplex.im",
|
||||
preset: false,
|
||||
tested: nil,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
deleted: false
|
||||
),
|
||||
xftpPreset: UserServer(
|
||||
serverId: 4,
|
||||
server: "xftp://abcd@xftp8.simplex.im",
|
||||
preset: true,
|
||||
tested: true,
|
||||
enabled: true,
|
||||
deleted: false
|
||||
)
|
||||
)
|
||||
|
||||
enum CodingKeys: CodingKey {
|
||||
case serverId
|
||||
case server
|
||||
case preset
|
||||
case tested
|
||||
case enabled
|
||||
case deleted
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1357,7 +1722,7 @@ public struct NetCfg: Codable, Equatable {
|
|||
public var hostMode: HostMode = .publicHost
|
||||
public var requiredHostMode = true
|
||||
public var sessionMode = TransportSessionMode.user
|
||||
public var smpProxyMode: SMPProxyMode = .unknown
|
||||
public var smpProxyMode: SMPProxyMode = .always
|
||||
public var smpProxyFallback: SMPProxyFallback = .allowProtected
|
||||
public var smpWebPort = false
|
||||
public var tcpConnectTimeout: Int // microseconds
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
package chat.simplex.common.views.usersettings
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import chat.simplex.common.model.ServerCfg
|
||||
import chat.simplex.common.model.UserServer
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) {
|
||||
actual fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
|
@ -15,7 +15,6 @@ import androidx.compose.ui.draw.*
|
|||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
@ -42,7 +41,6 @@ import dev.icerock.moko.resources.compose.painterResource
|
|||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@Composable
|
||||
fun AppScreen() {
|
||||
|
@ -194,6 +192,13 @@ fun MainScreen() {
|
|||
OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
|
||||
OnboardingStage.LinkAMobile -> LinkAMobile()
|
||||
OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
|
||||
OnboardingStage.Step3_ChooseServerOperators -> {
|
||||
val modalData = remember { ModalData() }
|
||||
modalData.ChooseServerOperators(true)
|
||||
if (appPlatform.isDesktop) {
|
||||
ModalManager.fullscreen.showInView()
|
||||
}
|
||||
}
|
||||
// Ensure backwards compatibility with old onboarding stage for address creation, otherwise notification setup would be skipped
|
||||
OnboardingStage.Step3_CreateSimpleXAddress -> SetNotificationsMode(chatModel)
|
||||
OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||
|
|
|
@ -145,6 +145,8 @@ object ChatModel {
|
|||
val clipboardHasText = mutableStateOf(false)
|
||||
val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true))
|
||||
|
||||
val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty)
|
||||
|
||||
val updatingProgress = mutableStateOf(null as Float?)
|
||||
var updatingRequest: Closeable? = null
|
||||
|
||||
|
@ -2567,6 +2569,13 @@ fun localTimestamp(t: Instant): String {
|
|||
return ts.toJavaLocalDateTime().format(dateFormatter)
|
||||
}
|
||||
|
||||
fun localDate(t: Instant): String {
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
val ts: LocalDateTime = t.toLocalDateTime(tz)
|
||||
val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
return ts.toJavaLocalDateTime().format(dateFormatter)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class CIStatus {
|
||||
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
|
||||
|
|
|
@ -26,10 +26,12 @@ import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert
|
|||
import chat.simplex.common.views.migration.MigrationFileLinkData
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.serverHostname
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import com.charleskorn.kaml.YamlConfiguration
|
||||
import chat.simplex.res.MR
|
||||
import com.russhwolf.settings.Settings
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
@ -963,36 +965,6 @@ object ChatController {
|
|||
return null
|
||||
}
|
||||
|
||||
suspend fun getUserProtoServers(rh: Long?, serverProtocol: ServerProtocol): UserProtocolServers? {
|
||||
val userId = kotlin.runCatching { currentUserId("getUserProtoServers") }.getOrElse { return null }
|
||||
val r = sendCmd(rh, CC.APIGetUserProtoServers(userId, serverProtocol))
|
||||
return if (r is CR.UserProtoServers) { if (rh == null) r.servers else r.servers.copy(protoServers = r.servers.protoServers.map { it.copy(remoteHostId = rh) }) }
|
||||
else {
|
||||
Log.e(TAG, "getUserProtoServers bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_loading_smp_servers else MR.strings.error_loading_xftp_servers),
|
||||
"${r.responseType}: ${r.details}"
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setUserProtoServers(rh: Long?, serverProtocol: ServerProtocol, servers: List<ServerCfg>): Boolean {
|
||||
val userId = kotlin.runCatching { currentUserId("setUserProtoServers") }.getOrElse { return false }
|
||||
val r = sendCmd(rh, CC.APISetUserProtoServers(userId, serverProtocol, servers))
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
else -> {
|
||||
Log.e(TAG, "setUserProtoServers bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_saving_smp_servers else MR.strings.error_saving_xftp_servers),
|
||||
generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.ensure_smp_server_address_are_correct_format_and_unique else MR.strings.ensure_xftp_server_address_are_correct_format_and_unique)
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? {
|
||||
val userId = currentUserId("testProtoServer")
|
||||
val r = sendCmd(rh, CC.APITestProtoServer(userId, server))
|
||||
|
@ -1005,6 +977,106 @@ object ChatController {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? {
|
||||
val r = sendCmd(rh, CC.ApiGetServerOperators())
|
||||
|
||||
return when (r) {
|
||||
is CR.ServerOperatorConditions -> r.conditions
|
||||
else -> {
|
||||
Log.e(TAG, "getServerOperators bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setServerOperators(rh: Long?, operators: List<ServerOperator>): ServerOperatorConditionsDetail? {
|
||||
val r = sendCmd(rh, CC.ApiSetServerOperators(operators))
|
||||
return when (r) {
|
||||
is CR.ServerOperatorConditions -> r.conditions
|
||||
else -> {
|
||||
Log.e(TAG, "setServerOperators bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getUserServers(rh: Long?): List<UserOperatorServers>? {
|
||||
val userId = currentUserId("getUserServers")
|
||||
val r = sendCmd(rh, CC.ApiGetUserServers(userId))
|
||||
return when (r) {
|
||||
is CR.UserServers -> r.userServers
|
||||
else -> {
|
||||
Log.e(TAG, "getUserServers bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setUserServers(rh: Long?, userServers: List<UserOperatorServers>): Boolean {
|
||||
val userId = currentUserId("setUserServers")
|
||||
val r = sendCmd(rh, CC.ApiSetUserServers(userId, userServers))
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
else -> {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.failed_to_save_servers),
|
||||
"${r.responseType}: ${r.details}"
|
||||
)
|
||||
Log.e(TAG, "setUserServers bad response: ${r.responseType} ${r.details}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun validateServers(rh: Long?, userServers: List<UserOperatorServers>): List<UserServersError>? {
|
||||
val userId = currentUserId("validateServers")
|
||||
val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers))
|
||||
return when (r) {
|
||||
is CR.UserServersValidation -> r.serverErrors
|
||||
else -> {
|
||||
Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getUsageConditions(rh: Long?): Triple<UsageConditionsDetail, String?, UsageConditionsDetail?>? {
|
||||
val r = sendCmd(rh, CC.ApiGetUsageConditions())
|
||||
return when (r) {
|
||||
is CR.UsageConditions -> Triple(r.usageConditions, r.conditionsText, r.acceptedConditions)
|
||||
else -> {
|
||||
Log.e(TAG, "getUsageConditions bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setConditionsNotified(rh: Long?, conditionsId: Long): Boolean {
|
||||
val r = sendCmd(rh, CC.ApiSetConditionsNotified(conditionsId))
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
else -> {
|
||||
Log.e(TAG, "setConditionsNotified bad response: ${r.responseType} ${r.details}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun acceptConditions(rh: Long?, conditionsId: Long, operatorIds: List<Long>): ServerOperatorConditionsDetail? {
|
||||
val r = sendCmd(rh, CC.ApiAcceptConditions(conditionsId, operatorIds))
|
||||
return when (r) {
|
||||
is CR.ServerOperatorConditions -> r.conditions
|
||||
else -> {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.error_accepting_operator_conditions),
|
||||
"${r.responseType}: ${r.details}"
|
||||
)
|
||||
Log.e(TAG, "acceptConditions bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getChatItemTTL(rh: Long?): ChatItemTTL {
|
||||
val userId = currentUserId("getChatItemTTL")
|
||||
val r = sendCmd(rh, CC.APIGetChatItemTTL(userId))
|
||||
|
@ -3037,9 +3109,15 @@ sealed class CC {
|
|||
class APIGetGroupLink(val groupId: Long): CC()
|
||||
class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC()
|
||||
class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC()
|
||||
class APIGetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol): CC()
|
||||
class APISetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol, val servers: List<ServerCfg>): CC()
|
||||
class APITestProtoServer(val userId: Long, val server: String): CC()
|
||||
class ApiGetServerOperators(): CC()
|
||||
class ApiSetServerOperators(val operators: List<ServerOperator>): CC()
|
||||
class ApiGetUserServers(val userId: Long): CC()
|
||||
class ApiSetUserServers(val userId: Long, val userServers: List<UserOperatorServers>): CC()
|
||||
class ApiValidateServers(val userId: Long, val userServers: List<UserOperatorServers>): CC()
|
||||
class ApiGetUsageConditions(): CC()
|
||||
class ApiSetConditionsNotified(val conditionsId: Long): CC()
|
||||
class ApiAcceptConditions(val conditionsId: Long, val operatorIds: List<Long>): CC()
|
||||
class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC()
|
||||
class APIGetChatItemTTL(val userId: Long): CC()
|
||||
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
|
||||
|
@ -3197,9 +3275,15 @@ sealed class CC {
|
|||
is APIGetGroupLink -> "/_get link #$groupId"
|
||||
is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId"
|
||||
is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}"
|
||||
is APIGetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()}"
|
||||
is APISetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()} ${protoServersStr(servers)}"
|
||||
is APITestProtoServer -> "/_server test $userId $server"
|
||||
is ApiGetServerOperators -> "/_operators"
|
||||
is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}"
|
||||
is ApiGetUserServers -> "/_servers $userId"
|
||||
is ApiSetUserServers -> "/_servers $userId ${json.encodeToString(userServers)}"
|
||||
is ApiValidateServers -> "/_validate_servers $userId ${json.encodeToString(userServers)}"
|
||||
is ApiGetUsageConditions -> "/_conditions"
|
||||
is ApiSetConditionsNotified -> "/_conditions_notified ${conditionsId}"
|
||||
is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}"
|
||||
is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}"
|
||||
is APIGetChatItemTTL -> "/_ttl $userId"
|
||||
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
|
||||
|
@ -3342,9 +3426,15 @@ sealed class CC {
|
|||
is APIGetGroupLink -> "apiGetGroupLink"
|
||||
is APICreateMemberContact -> "apiCreateMemberContact"
|
||||
is APISendMemberContactInvitation -> "apiSendMemberContactInvitation"
|
||||
is APIGetUserProtoServers -> "apiGetUserProtoServers"
|
||||
is APISetUserProtoServers -> "apiSetUserProtoServers"
|
||||
is APITestProtoServer -> "testProtoServer"
|
||||
is ApiGetServerOperators -> "apiGetServerOperators"
|
||||
is ApiSetServerOperators -> "apiSetServerOperators"
|
||||
is ApiGetUserServers -> "apiGetUserServers"
|
||||
is ApiSetUserServers -> "apiSetUserServers"
|
||||
is ApiValidateServers -> "apiValidateServers"
|
||||
is ApiGetUsageConditions -> "apiGetUsageConditions"
|
||||
is ApiSetConditionsNotified -> "apiSetConditionsNotified"
|
||||
is ApiAcceptConditions -> "apiAcceptConditions"
|
||||
is APISetChatItemTTL -> "apiSetChatItemTTL"
|
||||
is APIGetChatItemTTL -> "apiGetChatItemTTL"
|
||||
is APISetNetworkConfig -> "apiSetNetworkConfig"
|
||||
|
@ -3459,8 +3549,6 @@ sealed class CC {
|
|||
|
||||
companion object {
|
||||
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
|
||||
|
||||
fun protoServersStr(servers: List<ServerCfg>) = json.encodeToString(ProtoServersConfig(servers))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3510,24 +3598,350 @@ enum class ServerProtocol {
|
|||
}
|
||||
|
||||
@Serializable
|
||||
data class ProtoServersConfig(
|
||||
val servers: List<ServerCfg>
|
||||
enum class OperatorTag {
|
||||
@SerialName("simplex") SimpleX,
|
||||
@SerialName("flux") Flux,
|
||||
@SerialName("xyz") XYZ,
|
||||
@SerialName("demo") Demo
|
||||
}
|
||||
|
||||
data class ServerOperatorInfo(
|
||||
val description: List<String>,
|
||||
val website: String,
|
||||
val logo: ImageResource,
|
||||
val largeLogo: ImageResource,
|
||||
val logoDarkMode: ImageResource,
|
||||
val largeLogoDarkMode: ImageResource
|
||||
)
|
||||
val operatorsInfo: Map<OperatorTag, ServerOperatorInfo> = mapOf(
|
||||
OperatorTag.SimpleX to ServerOperatorInfo(
|
||||
description = listOf(
|
||||
"SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.",
|
||||
"SimpleX Chat Ltd develops the communication software for SimpleX network."
|
||||
),
|
||||
website = "https://simplex.chat",
|
||||
logo = MR.images.decentralized,
|
||||
largeLogo = MR.images.logo,
|
||||
logoDarkMode = MR.images.decentralized_light,
|
||||
largeLogoDarkMode = MR.images.logo_light
|
||||
),
|
||||
OperatorTag.Flux to ServerOperatorInfo(
|
||||
description = listOf(
|
||||
"Flux is the largest decentralized cloud infrastructure, leveraging a global network of user-operated computational nodes.",
|
||||
"Flux offers a powerful, scalable, and affordable platform designed to support individuals, businesses, and cutting-edge technologies like AI. With high uptime and worldwide distribution, Flux ensures reliable, accessible cloud computing for all."
|
||||
),
|
||||
website = "https://runonflux.com",
|
||||
logo = MR.images.flux_logo_symbol,
|
||||
largeLogo = MR.images.flux_logo,
|
||||
logoDarkMode = MR.images.flux_logo_symbol,
|
||||
largeLogoDarkMode = MR.images.flux_logo_light
|
||||
),
|
||||
OperatorTag.XYZ to ServerOperatorInfo(
|
||||
description = listOf("XYZ servers"),
|
||||
website = "XYZ website",
|
||||
logo = MR.images.shield,
|
||||
largeLogo = MR.images.logo,
|
||||
logoDarkMode = MR.images.shield,
|
||||
largeLogoDarkMode = MR.images.logo_light
|
||||
),
|
||||
OperatorTag.Demo to ServerOperatorInfo(
|
||||
description = listOf("Demo operator"),
|
||||
website = "Demo website",
|
||||
logo = MR.images.decentralized,
|
||||
largeLogo = MR.images.logo,
|
||||
logoDarkMode = MR.images.decentralized_light,
|
||||
largeLogoDarkMode = MR.images.logo_light
|
||||
)
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserProtocolServers(
|
||||
val serverProtocol: ServerProtocol,
|
||||
val protoServers: List<ServerCfg>,
|
||||
val presetServers: List<ServerCfg>,
|
||||
data class UsageConditionsDetail(
|
||||
val conditionsId: Long,
|
||||
val conditionsCommit: String,
|
||||
val notifiedAt: Instant?,
|
||||
val createdAt: Instant
|
||||
) {
|
||||
companion object {
|
||||
val sampleData = UsageConditionsDetail(
|
||||
conditionsId = 1,
|
||||
conditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c",
|
||||
notifiedAt = null,
|
||||
createdAt = Clock.System.now()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class UsageConditionsAction {
|
||||
@Serializable @SerialName("review") data class Review(val operators: List<ServerOperator>, val deadline: Instant?, val showNotice: Boolean) : UsageConditionsAction()
|
||||
@Serializable @SerialName("accepted") data class Accepted(val operators: List<ServerOperator>) : UsageConditionsAction()
|
||||
|
||||
val shouldShowNotice: Boolean
|
||||
get() = when (this) {
|
||||
is Review -> showNotice
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ServerOperatorConditionsDetail(
|
||||
val serverOperators: List<ServerOperator>,
|
||||
val currentConditions: UsageConditionsDetail,
|
||||
val conditionsAction: UsageConditionsAction?
|
||||
) {
|
||||
companion object {
|
||||
val empty = ServerOperatorConditionsDetail(
|
||||
serverOperators = emptyList(),
|
||||
currentConditions = UsageConditionsDetail(conditionsId = 0, conditionsCommit = "empty", notifiedAt = null, createdAt = Clock.System.now()),
|
||||
conditionsAction = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable()
|
||||
sealed class ConditionsAcceptance {
|
||||
@Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?) : ConditionsAcceptance()
|
||||
@Serializable @SerialName("required") data class Required(val deadline: Instant?) : ConditionsAcceptance()
|
||||
|
||||
val conditionsAccepted: Boolean
|
||||
get() = when (this) {
|
||||
is Accepted -> true
|
||||
is Required -> false
|
||||
}
|
||||
|
||||
val usageAllowed: Boolean
|
||||
get() = when (this) {
|
||||
is Accepted -> true
|
||||
is Required -> this.deadline != null
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ServerOperator(
|
||||
val operatorId: Long,
|
||||
val operatorTag: OperatorTag?,
|
||||
val tradeName: String,
|
||||
val legalName: String?,
|
||||
val serverDomains: List<String>,
|
||||
val conditionsAcceptance: ConditionsAcceptance,
|
||||
val enabled: Boolean,
|
||||
val smpRoles: ServerRoles,
|
||||
val xftpRoles: ServerRoles,
|
||||
) {
|
||||
companion object {
|
||||
val dummyOperatorInfo = ServerOperatorInfo(
|
||||
description = listOf("Default"),
|
||||
website = "Default",
|
||||
logo = MR.images.decentralized,
|
||||
largeLogo = MR.images.logo,
|
||||
logoDarkMode = MR.images.decentralized_light,
|
||||
largeLogoDarkMode = MR.images.logo_light
|
||||
)
|
||||
|
||||
val sampleData1 = ServerOperator(
|
||||
operatorId = 1,
|
||||
operatorTag = OperatorTag.SimpleX,
|
||||
tradeName = "SimpleX Chat",
|
||||
legalName = "SimpleX Chat Ltd",
|
||||
serverDomains = listOf("simplex.im"),
|
||||
conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null),
|
||||
enabled = true,
|
||||
smpRoles = ServerRoles(storage = true, proxy = true),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = true)
|
||||
)
|
||||
|
||||
val sampleData2 = ServerOperator(
|
||||
operatorId = 2,
|
||||
operatorTag = OperatorTag.XYZ,
|
||||
tradeName = "XYZ",
|
||||
legalName = null,
|
||||
serverDomains = listOf("xyz.com"),
|
||||
conditionsAcceptance = ConditionsAcceptance.Required(deadline = null),
|
||||
enabled = false,
|
||||
smpRoles = ServerRoles(storage = false, proxy = true),
|
||||
xftpRoles = ServerRoles(storage = false, proxy = true)
|
||||
)
|
||||
|
||||
val sampleData3 = ServerOperator(
|
||||
operatorId = 3,
|
||||
operatorTag = OperatorTag.Demo,
|
||||
tradeName = "Demo",
|
||||
legalName = null,
|
||||
serverDomains = listOf("demo.com"),
|
||||
conditionsAcceptance = ConditionsAcceptance.Required(deadline = null),
|
||||
enabled = false,
|
||||
smpRoles = ServerRoles(storage = true, proxy = false),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = false)
|
||||
)
|
||||
}
|
||||
|
||||
val id: Long
|
||||
get() = operatorId
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is ServerOperator) return false
|
||||
return other.operatorId == this.operatorId &&
|
||||
other.operatorTag == this.operatorTag &&
|
||||
other.tradeName == this.tradeName &&
|
||||
other.legalName == this.legalName &&
|
||||
other.serverDomains == this.serverDomains &&
|
||||
other.conditionsAcceptance == this.conditionsAcceptance &&
|
||||
other.enabled == this.enabled &&
|
||||
other.smpRoles == this.smpRoles &&
|
||||
other.xftpRoles == this.xftpRoles
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = operatorId.hashCode()
|
||||
result = 31 * result + (operatorTag?.hashCode() ?: 0)
|
||||
result = 31 * result + tradeName.hashCode()
|
||||
result = 31 * result + (legalName?.hashCode() ?: 0)
|
||||
result = 31 * result + serverDomains.hashCode()
|
||||
result = 31 * result + conditionsAcceptance.hashCode()
|
||||
result = 31 * result + enabled.hashCode()
|
||||
result = 31 * result + smpRoles.hashCode()
|
||||
result = 31 * result + xftpRoles.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
val legalName_: String
|
||||
get() = legalName ?: tradeName
|
||||
|
||||
val info: ServerOperatorInfo get() {
|
||||
return if (this.operatorTag != null) {
|
||||
operatorsInfo[this.operatorTag] ?: dummyOperatorInfo
|
||||
} else {
|
||||
dummyOperatorInfo
|
||||
}
|
||||
}
|
||||
|
||||
val logo: ImageResource
|
||||
@Composable
|
||||
get() {
|
||||
return if (isInDarkTheme()) info.logoDarkMode else info.logo
|
||||
}
|
||||
|
||||
val largeLogo: ImageResource
|
||||
@Composable
|
||||
get() {
|
||||
return if (isInDarkTheme()) info.largeLogoDarkMode else info.largeLogo
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ServerRoles(
|
||||
val storage: Boolean,
|
||||
val proxy: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ServerCfg(
|
||||
data class UserOperatorServers(
|
||||
val operator: ServerOperator?,
|
||||
val smpServers: List<UserServer>,
|
||||
val xftpServers: List<UserServer>
|
||||
) {
|
||||
val id: String
|
||||
get() = operator?.operatorId?.toString() ?: "nil operator"
|
||||
|
||||
val operator_: ServerOperator
|
||||
get() = operator ?: ServerOperator(
|
||||
operatorId = 0,
|
||||
operatorTag = null,
|
||||
tradeName = "",
|
||||
legalName = null,
|
||||
serverDomains = emptyList(),
|
||||
conditionsAcceptance = ConditionsAcceptance.Accepted(null),
|
||||
enabled = false,
|
||||
smpRoles = ServerRoles(storage = true, proxy = true),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = true)
|
||||
)
|
||||
|
||||
companion object {
|
||||
val sampleData1 = UserOperatorServers(
|
||||
operator = ServerOperator.sampleData1,
|
||||
smpServers = listOf(UserServer.sampleData.preset),
|
||||
xftpServers = listOf(UserServer.sampleData.xftpPreset)
|
||||
)
|
||||
|
||||
val sampleDataNilOperator = UserOperatorServers(
|
||||
operator = null,
|
||||
smpServers = listOf(UserServer.sampleData.preset),
|
||||
xftpServers = listOf(UserServer.sampleData.xftpPreset)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class UserServersError {
|
||||
@Serializable @SerialName("noServers") data class NoServers(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
|
||||
@Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
|
||||
@Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
|
||||
@Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError()
|
||||
|
||||
val globalError: String?
|
||||
get() = when (this.protocol_) {
|
||||
ServerProtocol.SMP -> globalSMPError
|
||||
ServerProtocol.XFTP -> globalXFTPError
|
||||
}
|
||||
|
||||
private val protocol_: ServerProtocol
|
||||
get() = when (this) {
|
||||
is NoServers -> this.protocol
|
||||
is StorageMissing -> this.protocol
|
||||
is ProxyMissing -> this.protocol
|
||||
is DuplicateServer -> this.protocol
|
||||
}
|
||||
|
||||
val globalSMPError: String?
|
||||
get() = if (this.protocol_ == ServerProtocol.SMP) {
|
||||
when (this) {
|
||||
is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured)}" }
|
||||
?: generalGetString(MR.strings.no_message_servers_configured)
|
||||
|
||||
is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_receiving)}" }
|
||||
?: generalGetString(MR.strings.no_message_servers_configured_for_receiving)
|
||||
|
||||
is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_private_routing)}" }
|
||||
?: generalGetString(MR.strings.no_message_servers_configured_for_private_routing)
|
||||
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val globalXFTPError: String?
|
||||
get() = if (this.protocol_ == ServerProtocol.XFTP) {
|
||||
when (this) {
|
||||
is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured)}" }
|
||||
?: generalGetString(MR.strings.no_media_servers_configured)
|
||||
|
||||
is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_sending)}" }
|
||||
?: generalGetString(MR.strings.no_media_servers_configured_for_sending)
|
||||
|
||||
is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_private_routing)}" }
|
||||
?: generalGetString(MR.strings.no_media_servers_configured_for_private_routing)
|
||||
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun userStr(user: UserRef): String {
|
||||
return String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UserServer(
|
||||
val remoteHostId: Long?,
|
||||
val serverId: Long?,
|
||||
val server: String,
|
||||
val preset: Boolean,
|
||||
val tested: Boolean? = null,
|
||||
val enabled: Boolean
|
||||
val enabled: Boolean,
|
||||
val deleted: Boolean
|
||||
) {
|
||||
@Transient
|
||||
private val createdAt: Date = Date()
|
||||
|
@ -3541,35 +3955,51 @@ data class ServerCfg(
|
|||
get() = server.isBlank()
|
||||
|
||||
companion object {
|
||||
val empty = ServerCfg(remoteHostId = null, server = "", preset = false, tested = null, enabled = false)
|
||||
val empty = UserServer(remoteHostId = null, serverId = null, server = "", preset = false, tested = null, enabled = false, deleted = false)
|
||||
|
||||
class SampleData(
|
||||
val preset: ServerCfg,
|
||||
val custom: ServerCfg,
|
||||
val untested: ServerCfg
|
||||
val preset: UserServer,
|
||||
val custom: UserServer,
|
||||
val untested: UserServer,
|
||||
val xftpPreset: UserServer
|
||||
)
|
||||
|
||||
val sampleData = SampleData(
|
||||
preset = ServerCfg(
|
||||
preset = UserServer(
|
||||
remoteHostId = null,
|
||||
serverId = 1,
|
||||
server = "smp://abcd@smp8.simplex.im",
|
||||
preset = true,
|
||||
tested = true,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
deleted = false
|
||||
),
|
||||
custom = ServerCfg(
|
||||
custom = UserServer(
|
||||
remoteHostId = null,
|
||||
serverId = 2,
|
||||
server = "smp://abcd@smp9.simplex.im",
|
||||
preset = false,
|
||||
tested = false,
|
||||
enabled = false
|
||||
enabled = false,
|
||||
deleted = false
|
||||
),
|
||||
untested = ServerCfg(
|
||||
untested = UserServer(
|
||||
remoteHostId = null,
|
||||
serverId = 3,
|
||||
server = "smp://abcd@smp10.simplex.im",
|
||||
preset = false,
|
||||
tested = null,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
deleted = false
|
||||
),
|
||||
xftpPreset = UserServer(
|
||||
remoteHostId = null,
|
||||
serverId = 4,
|
||||
server = "xftp://abcd@xftp8.simplex.im",
|
||||
preset = true,
|
||||
tested = true,
|
||||
enabled = true,
|
||||
deleted = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -3683,7 +4113,7 @@ data class NetCfg(
|
|||
val hostMode: HostMode = HostMode.OnionViaSocks,
|
||||
val requiredHostMode: Boolean = false,
|
||||
val sessionMode: TransportSessionMode = TransportSessionMode.default,
|
||||
val smpProxyMode: SMPProxyMode = SMPProxyMode.Unknown,
|
||||
val smpProxyMode: SMPProxyMode = SMPProxyMode.Always,
|
||||
val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.AllowProtected,
|
||||
val smpWebPort: Boolean = false,
|
||||
val tcpConnectTimeout: Long, // microseconds
|
||||
|
@ -4928,8 +5358,11 @@ sealed class CR {
|
|||
@Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List<Chat>): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR()
|
||||
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR()
|
||||
@Serializable @SerialName("userProtoServers") class UserProtoServers(val user: UserRef, val servers: UserProtocolServers): CR()
|
||||
@Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
|
||||
@Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR()
|
||||
@Serializable @SerialName("userServers") class UserServers(val user: UserRef, val userServers: List<UserOperatorServers>): CR()
|
||||
@Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List<UserServersError>): CR()
|
||||
@Serializable @SerialName("usageConditions") class UsageConditions(val usageConditions: UsageConditionsDetail, val conditionsText: String?, val acceptedConditions: UsageConditionsDetail?): CR()
|
||||
@Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR()
|
||||
@Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR()
|
||||
@Serializable @SerialName("contactInfo") class ContactInfo(val user: UserRef, val contact: Contact, val connectionStats_: ConnectionStats? = null, val customUserProfile: Profile? = null): CR()
|
||||
|
@ -5108,8 +5541,11 @@ sealed class CR {
|
|||
is ApiChats -> "apiChats"
|
||||
is ApiChat -> "apiChat"
|
||||
is ApiChatItemInfo -> "chatItemInfo"
|
||||
is UserProtoServers -> "userProtoServers"
|
||||
is ServerTestResult -> "serverTestResult"
|
||||
is ServerOperatorConditions -> "serverOperatorConditions"
|
||||
is UserServers -> "userServers"
|
||||
is UserServersValidation -> "userServersValidation"
|
||||
is UsageConditions -> "usageConditions"
|
||||
is ChatItemTTL -> "chatItemTTL"
|
||||
is NetworkConfig -> "networkConfig"
|
||||
is ContactInfo -> "contactInfo"
|
||||
|
@ -5278,8 +5714,11 @@ sealed class CR {
|
|||
is ApiChats -> withUser(user, json.encodeToString(chats))
|
||||
is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}")
|
||||
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}")
|
||||
is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}")
|
||||
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
|
||||
is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}"
|
||||
is UserServers -> withUser(user, "userServers: ${json.encodeToString(userServers)}")
|
||||
is UserServersValidation -> withUser(user, "serverErrors: ${json.encodeToString(serverErrors)}")
|
||||
is UsageConditions -> "usageConditions: ${json.encodeToString(usageConditions)}\nnacceptedConditions: ${json.encodeToString(acceptedConditions)}"
|
||||
is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL))
|
||||
is NetworkConfig -> json.encodeToString(networkConfig)
|
||||
is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats_)}")
|
||||
|
|
|
@ -118,6 +118,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
|
|||
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
|
||||
val user = chatController.apiGetActiveUser(null)
|
||||
chatModel.currentUser.value = user
|
||||
chatModel.conditions.value = chatController.getServerOperators(null) ?: ServerOperatorConditionsDetail.empty
|
||||
if (user == null) {
|
||||
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
|
||||
chatModel.currentUser.value = null
|
||||
|
@ -137,13 +138,12 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
|
|||
}
|
||||
} else if (startChat().await()) {
|
||||
val savedOnboardingStage = appPreferences.onboardingStage.get()
|
||||
val next = if (appPlatform.isAndroid) {
|
||||
OnboardingStage.Step4_SetNotificationsMode
|
||||
} else {
|
||||
OnboardingStage.OnboardingComplete
|
||||
}
|
||||
val newStage = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
|
||||
next
|
||||
if (appPlatform.isAndroid) {
|
||||
OnboardingStage.Step4_SetNotificationsMode
|
||||
} else {
|
||||
OnboardingStage.OnboardingComplete
|
||||
}
|
||||
} else {
|
||||
savedOnboardingStage
|
||||
}
|
||||
|
|
|
@ -165,7 +165,7 @@ fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) {
|
|||
if (!chatModel.connectedToRemote()) {
|
||||
chatModel.localUserCreated.value = true
|
||||
}
|
||||
controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
|
||||
controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators)
|
||||
controller.startChat(user)
|
||||
controller.switchUIRemoteHost(null)
|
||||
close()
|
||||
|
@ -204,7 +204,7 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: ()
|
|||
onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) {
|
||||
OnboardingStage.Step2_5_SetupDatabasePassphrase
|
||||
} else {
|
||||
OnboardingStage.Step4_SetNotificationsMode
|
||||
OnboardingStage.Step3_ChooseServerOperators
|
||||
})
|
||||
} else {
|
||||
// the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen,
|
||||
|
|
|
@ -1260,7 +1260,7 @@ fun BoxScope.ChatItemsList(
|
|||
} else {
|
||||
null
|
||||
}
|
||||
val showAvatar = if (merged is MergedItem.Grouped) shouldShowAvatar(item, listItem.nextItem) else true
|
||||
val showAvatar = shouldShowAvatar(item, listItem.nextItem)
|
||||
val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } }
|
||||
val itemSeparation: ItemSeparation
|
||||
val prevItemSeparationLargeGap: Boolean
|
||||
|
|
|
@ -25,19 +25,21 @@ import androidx.compose.ui.unit.*
|
|||
import chat.simplex.common.AppLock
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatController.setConditionsNotified
|
||||
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.WhatsNewView
|
||||
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.call.Call
|
||||
import chat.simplex.common.views.chat.item.CIFileViewScope
|
||||
import chat.simplex.common.views.chat.topPaddingToContent
|
||||
import chat.simplex.common.views.mkValidName
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.common.views.onboarding.*
|
||||
import chat.simplex.common.views.showInvalidNameAlert
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
@ -115,10 +117,26 @@ fun ToggleChatListCard() {
|
|||
@Composable
|
||||
fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val rhId = chatModel.remoteHostId()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (shouldShowWhatsNew(chatModel)) {
|
||||
val showWhatsNew = shouldShowWhatsNew(chatModel)
|
||||
val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false
|
||||
if (showWhatsNew) {
|
||||
delay(1000L)
|
||||
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
|
||||
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close, updatedConditions = showUpdatedConditions) }
|
||||
} else if (showUpdatedConditions) {
|
||||
ModalManager.center.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
|
||||
LaunchedEffect(Unit) {
|
||||
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
|
||||
try {
|
||||
setConditionsNotified(rh = rhId, conditionsId = conditionsId)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "UsageConditionsView setConditionsNotified error: ${e.message}")
|
||||
}
|
||||
}
|
||||
UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -48,7 +48,6 @@ import chat.simplex.common.model.localTimestamp
|
|||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.ProtocolServersView
|
||||
import chat.simplex.common.views.usersettings.SettingsPreferenceItem
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
|
@ -540,15 +539,8 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant,
|
|||
)
|
||||
)
|
||||
}
|
||||
if (summary.known == true) {
|
||||
SectionItemView(click = {
|
||||
ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.XFTP, close) }
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.open_server_settings_button))
|
||||
}
|
||||
if (summary.stats != null || summary.sessions != null) {
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
if (summary.stats != null || summary.sessions != null) {
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
if (summary.stats != null) {
|
||||
|
@ -579,12 +571,7 @@ fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, r
|
|||
)
|
||||
)
|
||||
}
|
||||
if (summary.known == true) {
|
||||
SectionItemView(click = {
|
||||
ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.SMP, close) }
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.open_server_settings_button))
|
||||
}
|
||||
if (summary.stats != null || summary.subs != null || summary.sessions != null) {
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
|
|
|
@ -17,11 +17,21 @@ import dev.icerock.moko.resources.compose.painterResource
|
|||
import kotlin.math.absoluteValue
|
||||
|
||||
@Composable
|
||||
fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) {
|
||||
fun AppBarTitle(
|
||||
title: String,
|
||||
hostDevice: Pair<Long?, String>? = null,
|
||||
withPadding: Boolean = true,
|
||||
bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp,
|
||||
enableAlphaChanges: Boolean = true
|
||||
) {
|
||||
val handler = LocalAppBarHandler.current
|
||||
val connection = handler?.connection
|
||||
val connection = if (enableAlphaChanges) handler?.connection else null
|
||||
LaunchedEffect(title) {
|
||||
handler?.title?.value = title
|
||||
if (enableAlphaChanges) {
|
||||
handler?.title?.value = title
|
||||
} else {
|
||||
handler?.connection?.scrollTrackingEnabled = false
|
||||
}
|
||||
}
|
||||
val theme = CurrentColors.collectAsState()
|
||||
val titleColor = MaterialTheme.appColors.title
|
||||
|
@ -54,7 +64,8 @@ fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPad
|
|||
}
|
||||
|
||||
private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) =
|
||||
if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
|
||||
if (connection?.scrollTrackingEnabled == false) 1f
|
||||
else if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
|
||||
else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx
|
||||
|
||||
@Composable
|
||||
|
|
|
@ -84,6 +84,7 @@ class AppBarHandler(
|
|||
}
|
||||
|
||||
class CollapsingAppBarNestedScrollConnection(): NestedScrollConnection {
|
||||
var scrollTrackingEnabled = true
|
||||
var appBarOffset: Float by mutableFloatStateOf(0f)
|
||||
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
|
|
|
@ -258,7 +258,8 @@ private fun AppBarCenterAligned(
|
|||
}
|
||||
|
||||
private fun topTitleAlpha(text: Boolean, connection: CollapsingAppBarNestedScrollConnection, alpha: Float = appPrefs.inAppBarsAlpha.get()) =
|
||||
if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
|
||||
if (!connection.scrollTrackingEnabled) 0f
|
||||
else if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
|
||||
else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, if (text) 1f else alpha)
|
||||
|
||||
val AppBarHeight = 56.dp
|
||||
|
|
|
@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword
|
|||
import chat.simplex.common.views.newchat.QRCodeScanner
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -48,8 +48,8 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>?
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current) {
|
||||
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style)
|
||||
fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current, args: Any? = null) {
|
||||
Text(annotatedStringResource(stringResId, args), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
@ -5,6 +5,7 @@ enum class OnboardingStage {
|
|||
Step2_CreateProfile,
|
||||
LinkAMobile,
|
||||
Step2_5_SetupDatabasePassphrase,
|
||||
Step3_ChooseServerOperators,
|
||||
Step3_CreateSimpleXAddress,
|
||||
Step4_SetNotificationsMode,
|
||||
OnboardingComplete
|
||||
|
|
|
@ -16,8 +16,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.changeNotificationsMode
|
||||
|
@ -26,7 +25,7 @@ import chat.simplex.res.MR
|
|||
@Composable
|
||||
fun SetNotificationsMode(m: ChatModel) {
|
||||
LaunchedEffect(Unit) {
|
||||
prepareChatBeforeNotificationsSetup(m)
|
||||
prepareChatBeforeFinishingOnboarding()
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
|
||||
|
@ -57,6 +56,7 @@ fun SetNotificationsMode(m: ChatModel) {
|
|||
onboarding = OnboardingStage.OnboardingComplete,
|
||||
onclick = {
|
||||
changeNotificationsMode(currentMode.value, m)
|
||||
ModalManager.fullscreen.closeModals()
|
||||
}
|
||||
)
|
||||
// Reserve space
|
||||
|
@ -99,7 +99,7 @@ fun <T> SelectableCard(currentValue: State<T>, newValue: T, title: String, descr
|
|||
Spacer(Modifier.height(14.dp))
|
||||
}
|
||||
|
||||
private fun prepareChatBeforeNotificationsSetup(chatModel: ChatModel) {
|
||||
fun prepareChatBeforeFinishingOnboarding() {
|
||||
// No visible users but may have hidden. In this case chat should be started anyway because it's stopped on this stage with hidden users
|
||||
if (chatModel.users.any { u -> !u.user.hidden }) return
|
||||
withBGApi {
|
||||
|
|
|
@ -17,7 +17,6 @@ import dev.icerock.moko.resources.compose.stringResource
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.database.*
|
||||
|
@ -36,7 +35,7 @@ fun SetupDatabasePassphrase(m: ChatModel) {
|
|||
val confirmNewKey = rememberSaveable { mutableStateOf("") }
|
||||
fun nextStep() {
|
||||
if (appPlatform.isAndroid || chatModel.currentUser.value != null) {
|
||||
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
|
||||
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators)
|
||||
} else {
|
||||
m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile)
|
||||
}
|
||||
|
|
|
@ -164,14 +164,15 @@ fun OnboardingActionButton(
|
|||
@Composable
|
||||
fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?) {
|
||||
val state = getKeyboardState()
|
||||
val enabled = onClick != null
|
||||
val topPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING)
|
||||
val bottomPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING * 2)
|
||||
if ((appPlatform.isAndroid && state.value == KeyboardState.Closed) || topPadding > 0.dp) {
|
||||
TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = onClick != null) {
|
||||
TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = enabled) {
|
||||
Text(
|
||||
text,
|
||||
Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF, bottom = 5.dp),
|
||||
color = MaterialTheme.colors.primary,
|
||||
color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
|
|
@ -8,7 +8,6 @@ import androidx.compose.runtime.*
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
@ -17,17 +16,32 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.setConditionsNotified
|
||||
import chat.simplex.common.model.ServerOperator.Companion.dummyOperatorInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
|
||||
@Composable
|
||||
fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
||||
fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Boolean = false, close: () -> Unit) {
|
||||
val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) }
|
||||
val rhId = chatModel.remoteHostId()
|
||||
|
||||
if (updatedConditions) {
|
||||
LaunchedEffect(Unit) {
|
||||
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
|
||||
try {
|
||||
setConditionsNotified(rh = rhId, conditionsId = conditionsId)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "WhatsNewView setConditionsNotified error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun featureDescription(icon: ImageResource?, titleId: StringResource, descrId: StringResource?, link: String?, subfeatures: List<Pair<ImageResource, StringResource>>) {
|
||||
|
@ -124,9 +138,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
|||
) {
|
||||
AppBarTitle(String.format(generalGetString(MR.strings.new_in_version), v.version), withPadding = false, bottomPadding = DEFAULT_PADDING)
|
||||
|
||||
val modalManager = if (viaSettings) ModalManager.start else ModalManager.center
|
||||
|
||||
v.features.forEach { feature ->
|
||||
if (feature.show) {
|
||||
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures)
|
||||
when (feature) {
|
||||
is VersionFeature.FeatureDescription -> {
|
||||
if (feature.show) {
|
||||
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures)
|
||||
}
|
||||
}
|
||||
is VersionFeature.FeatureView -> {
|
||||
feature.view(modalManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -134,6 +157,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
|||
ReadMoreButton(v.post)
|
||||
}
|
||||
|
||||
if (updatedConditions) {
|
||||
Text(
|
||||
stringResource(MR.strings.view_updated_conditions),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable {
|
||||
modalManager.showModalCloseable {
|
||||
close -> UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!viaSettings) {
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(
|
||||
|
@ -141,7 +176,9 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
|||
) {
|
||||
Text(
|
||||
generalGetString(MR.strings.ok),
|
||||
modifier = Modifier.clickable(onClick = close),
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
close()
|
||||
}),
|
||||
style = MaterialTheme.typography.h3,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
|
@ -166,18 +203,26 @@ fun ReadMoreButton(url: String) {
|
|||
}
|
||||
}
|
||||
|
||||
private data class FeatureDescription(
|
||||
val icon: ImageResource?,
|
||||
val titleId: StringResource,
|
||||
val descrId: StringResource?,
|
||||
var subfeatures: List<Pair<ImageResource, StringResource>> = listOf(),
|
||||
val link: String? = null,
|
||||
val show: Boolean = true
|
||||
)
|
||||
private sealed class VersionFeature {
|
||||
class FeatureDescription(
|
||||
val icon: ImageResource?,
|
||||
val titleId: StringResource,
|
||||
val descrId: StringResource?,
|
||||
var subfeatures: List<Pair<ImageResource, StringResource>> = listOf(),
|
||||
val link: String? = null,
|
||||
val show: Boolean = true
|
||||
): VersionFeature()
|
||||
|
||||
class FeatureView(
|
||||
val icon: ImageResource?,
|
||||
val titleId: StringResource,
|
||||
val view: @Composable (modalManager: ModalManager) -> Unit
|
||||
): VersionFeature()
|
||||
}
|
||||
|
||||
private data class VersionDescription(
|
||||
val version: String,
|
||||
val features: List<FeatureDescription>,
|
||||
val features: List<VersionFeature>,
|
||||
val post: String? = null,
|
||||
)
|
||||
|
||||
|
@ -186,18 +231,18 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v4.2",
|
||||
post = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_verified_user,
|
||||
titleId = MR.strings.v4_2_security_assessment,
|
||||
descrId = MR.strings.v4_2_security_assessment_desc,
|
||||
link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_group,
|
||||
titleId = MR.strings.v4_2_group_links,
|
||||
descrId = MR.strings.v4_2_group_links_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_check,
|
||||
titleId = MR.strings.v4_2_auto_accept_contact_requests,
|
||||
descrId = MR.strings.v4_2_auto_accept_contact_requests_desc
|
||||
|
@ -208,22 +253,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v4.3",
|
||||
post = "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_mic,
|
||||
titleId = MR.strings.v4_3_voice_messages,
|
||||
descrId = MR.strings.v4_3_voice_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_delete_forever,
|
||||
titleId = MR.strings.v4_3_irreversible_message_deletion,
|
||||
descrId = MR.strings.v4_3_irreversible_message_deletion_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_wifi_tethering,
|
||||
titleId = MR.strings.v4_3_improved_server_configuration,
|
||||
descrId = MR.strings.v4_3_improved_server_configuration_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_visibility_off,
|
||||
titleId = MR.strings.v4_3_improved_privacy_and_security,
|
||||
descrId = MR.strings.v4_3_improved_privacy_and_security_desc
|
||||
|
@ -234,22 +279,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v4.4",
|
||||
post = "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_timer,
|
||||
titleId = MR.strings.v4_4_disappearing_messages,
|
||||
descrId = MR.strings.v4_4_disappearing_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_pending,
|
||||
titleId = MR.strings.v4_4_live_messages,
|
||||
descrId = MR.strings.v4_4_live_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_verified_user,
|
||||
titleId = MR.strings.v4_4_verify_connection_security,
|
||||
descrId = MR.strings.v4_4_verify_connection_security_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v4_4_french_interface,
|
||||
descrId = MR.strings.v4_4_french_interface_descr
|
||||
|
@ -260,33 +305,33 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v4.5",
|
||||
post = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_manage_accounts,
|
||||
titleId = MR.strings.v4_5_multiple_chat_profiles,
|
||||
descrId = MR.strings.v4_5_multiple_chat_profiles_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_edit_note,
|
||||
titleId = MR.strings.v4_5_message_draft,
|
||||
descrId = MR.strings.v4_5_message_draft_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_safety_divider,
|
||||
titleId = MR.strings.v4_5_transport_isolation,
|
||||
descrId = MR.strings.v4_5_transport_isolation_descr,
|
||||
link = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation"
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_task,
|
||||
titleId = MR.strings.v4_5_private_filenames,
|
||||
descrId = MR.strings.v4_5_private_filenames_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_battery_2_bar,
|
||||
titleId = MR.strings.v4_5_reduced_battery_usage,
|
||||
descrId = MR.strings.v4_5_reduced_battery_usage_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v4_5_italian_interface,
|
||||
descrId = MR.strings.v4_5_italian_interface_descr,
|
||||
|
@ -297,32 +342,32 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v4.6",
|
||||
post = "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_lock,
|
||||
titleId = MR.strings.v4_6_hidden_chat_profiles,
|
||||
descrId = MR.strings.v4_6_hidden_chat_profiles_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_flag,
|
||||
titleId = MR.strings.v4_6_group_moderation,
|
||||
descrId = MR.strings.v4_6_group_moderation_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_maps_ugc,
|
||||
titleId = MR.strings.v4_6_group_welcome_message,
|
||||
descrId = MR.strings.v4_6_group_welcome_message_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_call,
|
||||
titleId = MR.strings.v4_6_audio_video_calls,
|
||||
descrId = MR.strings.v4_6_audio_video_calls_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_battery_3_bar,
|
||||
titleId = MR.strings.v4_6_reduced_battery_usage,
|
||||
descrId = MR.strings.v4_6_reduced_battery_usage_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v4_6_chinese_spanish_interface,
|
||||
descrId = MR.strings.v4_6_chinese_spanish_interface_descr,
|
||||
|
@ -333,17 +378,17 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v5.0",
|
||||
post = "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_upload_file,
|
||||
titleId = MR.strings.v5_0_large_files_support,
|
||||
descrId = MR.strings.v5_0_large_files_support_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_lock,
|
||||
titleId = MR.strings.v5_0_app_passcode,
|
||||
descrId = MR.strings.v5_0_app_passcode_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_0_polish_interface,
|
||||
descrId = MR.strings.v5_0_polish_interface_descr,
|
||||
|
@ -354,27 +399,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v5.1",
|
||||
post = "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_add_reaction,
|
||||
titleId = MR.strings.v5_1_message_reactions,
|
||||
descrId = MR.strings.v5_1_message_reactions_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_chat,
|
||||
titleId = MR.strings.v5_1_better_messages,
|
||||
descrId = MR.strings.v5_1_better_messages_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_light_mode,
|
||||
titleId = MR.strings.v5_1_custom_themes,
|
||||
descrId = MR.strings.v5_1_custom_themes_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_lock,
|
||||
titleId = MR.strings.v5_1_self_destruct_passcode,
|
||||
descrId = MR.strings.v5_1_self_destruct_passcode_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_1_japanese_portuguese_interface,
|
||||
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
|
||||
|
@ -385,27 +430,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v5.2",
|
||||
post = "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_check,
|
||||
titleId = MR.strings.v5_2_message_delivery_receipts,
|
||||
descrId = MR.strings.v5_2_message_delivery_receipts_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_star,
|
||||
titleId = MR.strings.v5_2_favourites_filter,
|
||||
descrId = MR.strings.v5_2_favourites_filter_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_sync_problem,
|
||||
titleId = MR.strings.v5_2_fix_encryption,
|
||||
descrId = MR.strings.v5_2_fix_encryption_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_timer,
|
||||
titleId = MR.strings.v5_2_disappear_one_message,
|
||||
descrId = MR.strings.v5_2_disappear_one_message_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_redeem,
|
||||
titleId = MR.strings.v5_2_more_things,
|
||||
descrId = MR.strings.v5_2_more_things_descr
|
||||
|
@ -416,29 +461,29 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v5.3",
|
||||
post = "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_desktop,
|
||||
titleId = MR.strings.v5_3_new_desktop_app,
|
||||
descrId = MR.strings.v5_3_new_desktop_app_descr,
|
||||
link = "https://simplex.chat/downloads/"
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_lock,
|
||||
titleId = MR.strings.v5_3_encrypt_local_files,
|
||||
descrId = MR.strings.v5_3_encrypt_local_files_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_search,
|
||||
titleId = MR.strings.v5_3_discover_join_groups,
|
||||
descrId = MR.strings.v5_3_discover_join_groups_descr,
|
||||
link = "simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_theater_comedy,
|
||||
titleId = MR.strings.v5_3_simpler_incognito_mode,
|
||||
descrId = MR.strings.v5_3_simpler_incognito_mode_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_3_new_interface_languages,
|
||||
descrId = MR.strings.v5_3_new_interface_languages_descr,
|
||||
|
@ -449,27 +494,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v5.4",
|
||||
post = "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_desktop,
|
||||
titleId = MR.strings.v5_4_link_mobile_desktop,
|
||||
descrId = MR.strings.v5_4_link_mobile_desktop_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_group,
|
||||
titleId = MR.strings.v5_4_better_groups,
|
||||
descrId = MR.strings.v5_4_better_groups_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_theater_comedy,
|
||||
titleId = MR.strings.v5_4_incognito_groups,
|
||||
descrId = MR.strings.v5_4_incognito_groups_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_back_hand,
|
||||
titleId = MR.strings.v5_4_block_group_members,
|
||||
descrId = MR.strings.v5_4_block_group_members_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_redeem,
|
||||
titleId = MR.strings.v5_2_more_things,
|
||||
descrId = MR.strings.v5_4_more_things_descr
|
||||
|
@ -480,28 +525,28 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v5.5",
|
||||
post = "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_folder_pen,
|
||||
titleId = MR.strings.v5_5_private_notes,
|
||||
descrId = MR.strings.v5_5_private_notes_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_link,
|
||||
titleId = MR.strings.v5_5_simpler_connect_ui,
|
||||
descrId = MR.strings.v5_5_simpler_connect_ui_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_forum,
|
||||
titleId = MR.strings.v5_5_join_group_conversation,
|
||||
descrId = MR.strings.v5_5_join_group_conversation_descr,
|
||||
link = "simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_battery_3_bar,
|
||||
titleId = MR.strings.v5_5_message_delivery,
|
||||
descrId = MR.strings.v5_5_message_delivery_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_5_new_interface_languages,
|
||||
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
|
||||
|
@ -512,22 +557,22 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v5.6",
|
||||
post = "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_vpn_key_filled,
|
||||
titleId = MR.strings.v5_6_quantum_resistant_encryption,
|
||||
descrId = MR.strings.v5_6_quantum_resistant_encryption_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_ios_share,
|
||||
titleId = MR.strings.v5_6_app_data_migration,
|
||||
descrId = MR.strings.v5_6_app_data_migration_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_call,
|
||||
titleId = MR.strings.v5_6_picture_in_picture_calls,
|
||||
descrId = MR.strings.v5_6_picture_in_picture_calls_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_back_hand,
|
||||
titleId = MR.strings.v5_6_safer_groups,
|
||||
descrId = MR.strings.v5_6_safer_groups_descr
|
||||
|
@ -538,32 +583,32 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v5.7",
|
||||
post = "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_vpn_key_filled,
|
||||
titleId = MR.strings.v5_6_quantum_resistant_encryption,
|
||||
descrId = MR.strings.v5_7_quantum_resistant_encryption_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_forward,
|
||||
titleId = MR.strings.v5_7_forward,
|
||||
descrId = MR.strings.v5_7_forward_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_music_note,
|
||||
titleId = MR.strings.v5_7_call_sounds,
|
||||
descrId = MR.strings.v5_7_call_sounds_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_account_box,
|
||||
titleId = MR.strings.v5_7_shape_profile_images,
|
||||
descrId = MR.strings.v5_7_shape_profile_images_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_wifi_tethering,
|
||||
titleId = MR.strings.v5_7_network,
|
||||
descrId = MR.strings.v5_7_network_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_7_new_interface_languages,
|
||||
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate,
|
||||
|
@ -574,27 +619,27 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v5.8",
|
||||
post = "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_settings_ethernet,
|
||||
titleId = MR.strings.v5_8_private_routing,
|
||||
descrId = MR.strings.v5_8_private_routing_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_palette,
|
||||
titleId = MR.strings.v5_8_chat_themes,
|
||||
descrId = MR.strings.v5_8_chat_themes_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_security,
|
||||
titleId = MR.strings.v5_8_safe_files,
|
||||
descrId = MR.strings.v5_8_safe_files_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_battery_3_bar,
|
||||
titleId = MR.strings.v5_8_message_delivery,
|
||||
descrId = MR.strings.v5_8_message_delivery_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_translate,
|
||||
titleId = MR.strings.v5_8_persian_ui,
|
||||
descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate
|
||||
|
@ -605,7 +650,7 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v6.0",
|
||||
post = "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = null,
|
||||
titleId = MR.strings.v6_0_new_chat_experience,
|
||||
descrId = null,
|
||||
|
@ -616,7 +661,7 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
MR.images.ic_match_case to MR.strings.v6_0_increase_font_size
|
||||
)
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = null,
|
||||
titleId = MR.strings.v6_0_new_media_options,
|
||||
descrId = null,
|
||||
|
@ -625,23 +670,23 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
MR.images.ic_blur_on to MR.strings.v6_0_privacy_blur,
|
||||
)
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_toast,
|
||||
titleId = MR.strings.v6_0_reachable_chat_toolbar,
|
||||
descrId = MR.strings.v6_0_reachable_chat_toolbar_descr,
|
||||
show = appPlatform.isAndroid
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_settings_ethernet,
|
||||
titleId = MR.strings.v5_8_private_routing,
|
||||
descrId = MR.strings.v6_0_private_routing_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_wifi_tethering,
|
||||
titleId = MR.strings.v6_0_connection_servers_status,
|
||||
descrId = MR.strings.v6_0_connection_servers_status_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_upgrade,
|
||||
titleId = MR.strings.v6_0_upgrade_app,
|
||||
descrId = MR.strings.v6_0_upgrade_app_descr,
|
||||
|
@ -653,18 +698,18 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
version = "v6.1",
|
||||
post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_verified_user,
|
||||
titleId = MR.strings.v6_1_better_security,
|
||||
descrId = MR.strings.v6_1_better_security_descr,
|
||||
link = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_videocam,
|
||||
titleId = MR.strings.v6_1_better_calls,
|
||||
descrId = MR.strings.v6_1_better_calls_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = null,
|
||||
titleId = MR.strings.v6_1_better_user_experience,
|
||||
descrId = null,
|
||||
|
@ -678,6 +723,39 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
),
|
||||
),
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v6.2-beta.1",
|
||||
post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html",
|
||||
features = listOf(
|
||||
VersionFeature.FeatureView(
|
||||
icon = null,
|
||||
titleId = MR.strings.v6_2_network_decentralization,
|
||||
view = { modalManager ->
|
||||
Column {
|
||||
val src = (operatorsInfo[OperatorTag.Flux] ?: dummyOperatorInfo).largeLogo
|
||||
Image(painterResource(src), null, modifier = Modifier.height(48.dp))
|
||||
Text(stringResource(MR.strings.v6_2_network_decentralization_descr), modifier = Modifier.padding(top = 8.dp))
|
||||
Row {
|
||||
Text(
|
||||
stringResource(MR.strings.v6_2_network_decentralization_enable_flux),
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable {
|
||||
modalManager.showModalCloseable { close -> ChooseServerOperators(onboarding = false, close, modalManager) }
|
||||
}
|
||||
)
|
||||
Text(" ")
|
||||
Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux_reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
VersionFeature.FeatureDescription(
|
||||
icon = MR.images.ic_chat,
|
||||
titleId = MR.strings.v6_2_improved_chat_navigation,
|
||||
descrId = MR.strings.v6_2_improved_chat_navigation_descr
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
private val lastVersion = versionDescriptions.last().version
|
||||
|
@ -700,7 +778,8 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
|
|||
@Composable
|
||||
fun PreviewWhatsNewView() {
|
||||
SimpleXTheme {
|
||||
WhatsNewView(
|
||||
val data = remember { ModalData() }
|
||||
data.WhatsNewView(
|
||||
viaSettings = true,
|
||||
close = {}
|
||||
)
|
||||
|
|
|
@ -35,6 +35,7 @@ import chat.simplex.common.views.chatlist.*
|
|||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.QRCode
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.validPort
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -25,14 +25,13 @@ import chat.simplex.common.model.*
|
|||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.CreateProfile
|
||||
import chat.simplex.common.views.database.DatabaseView
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.migration.MigrateFromDeviceView
|
||||
import chat.simplex.common.views.onboarding.SimpleXInfo
|
||||
import chat.simplex.common.views.onboarding.WhatsNewView
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@Composable
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) {
|
||||
|
@ -102,7 +101,7 @@ fun SettingsLayout(
|
|||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
|
||||
SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) })
|
||||
|
@ -118,7 +117,7 @@ fun SettingsLayout(
|
|||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_help)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close = close) }, disabled = stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package chat.simplex.common.views.usersettings
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
|
@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.*
|
|||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.views.usersettings.SettingsPreferenceItem
|
||||
import chat.simplex.res.MR
|
||||
import java.text.DecimalFormat
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package chat.simplex.common.views.usersettings
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionCustomFooter
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionItemWithValue
|
||||
|
@ -20,119 +21,245 @@ import dev.icerock.moko.resources.compose.stringResource
|
|||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatController.getServerOperators
|
||||
import chat.simplex.common.model.ChatController.getUserServers
|
||||
import chat.simplex.common.model.ChatController.setUserServers
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingActionButton
|
||||
import chat.simplex.common.views.onboarding.ReadableText
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NetworkAndServersView() {
|
||||
fun ModalData.NetworkAndServersView(close: () -> Unit) {
|
||||
val currentRemoteHost by remember { chatModel.currentRemoteHost }
|
||||
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
|
||||
val netCfg = remember { chatModel.controller.getNetCfg() }
|
||||
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) }
|
||||
val currUserServers = remember { stateGetOrPut("currUserServers") { emptyList<UserOperatorServers>() } }
|
||||
val userServers = remember { stateGetOrPut("userServers") { emptyList<UserOperatorServers>() } }
|
||||
val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList<UserServersError>() } }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } }
|
||||
NetworkAndServersLayout(
|
||||
currentRemoteHost = currentRemoteHost,
|
||||
networkUseSocksProxy = networkUseSocksProxy,
|
||||
onionHosts = remember { mutableStateOf(netCfg.onionHosts) },
|
||||
toggleSocksProxy = { enable ->
|
||||
val def = NetCfg.defaults
|
||||
val proxyDef = NetCfg.proxyDefaults
|
||||
if (enable) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.network_enable_socks),
|
||||
text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value),
|
||||
confirmText = generalGetString(MR.strings.confirm_verb),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get())
|
||||
if (conf.tcpConnectTimeout == def.tcpConnectTimeout) {
|
||||
conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout)
|
||||
}
|
||||
if (conf.tcpTimeout == def.tcpTimeout) {
|
||||
conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout)
|
||||
}
|
||||
if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) {
|
||||
conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb)
|
||||
}
|
||||
if (conf.rcvConcurrency == def.rcvConcurrency) {
|
||||
conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency)
|
||||
}
|
||||
chatModel.controller.apiSetNetworkConfig(conf)
|
||||
chatModel.controller.setNetCfg(conf)
|
||||
networkUseSocksProxy.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
ModalView(
|
||||
close = {
|
||||
if (!serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)) {
|
||||
close()
|
||||
} else {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.network_disable_socks),
|
||||
text = generalGetString(MR.strings.network_disable_socks_info),
|
||||
confirmText = generalGetString(MR.strings.confirm_verb),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
var conf = controller.getNetCfg().copy(socksProxy = null)
|
||||
if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) {
|
||||
conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout)
|
||||
}
|
||||
if (conf.tcpTimeout == proxyDef.tcpTimeout) {
|
||||
conf = conf.copy(tcpTimeout = def.tcpTimeout)
|
||||
}
|
||||
if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) {
|
||||
conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb)
|
||||
}
|
||||
if (conf.rcvConcurrency == proxyDef.rcvConcurrency) {
|
||||
conf = conf.copy(rcvConcurrency = def.rcvConcurrency)
|
||||
}
|
||||
chatModel.controller.apiSetNetworkConfig(conf)
|
||||
chatModel.controller.setNetCfg(conf)
|
||||
networkUseSocksProxy.value = false
|
||||
}
|
||||
}
|
||||
showUnsavedChangesAlert(
|
||||
{ scope.launch { saveServers(currentRemoteHost?.remoteHostId, currUserServers, userServers) }},
|
||||
close
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
NetworkAndServersLayout(
|
||||
currentRemoteHost = currentRemoteHost,
|
||||
networkUseSocksProxy = networkUseSocksProxy,
|
||||
onionHosts = remember { mutableStateOf(netCfg.onionHosts) },
|
||||
currUserServers = currUserServers,
|
||||
userServers = userServers,
|
||||
serverErrors = serverErrors,
|
||||
toggleSocksProxy = { enable ->
|
||||
val def = NetCfg.defaults
|
||||
val proxyDef = NetCfg.proxyDefaults
|
||||
if (enable) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.network_enable_socks),
|
||||
text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value),
|
||||
confirmText = generalGetString(MR.strings.confirm_verb),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get())
|
||||
if (conf.tcpConnectTimeout == def.tcpConnectTimeout) {
|
||||
conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout)
|
||||
}
|
||||
if (conf.tcpTimeout == def.tcpTimeout) {
|
||||
conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout)
|
||||
}
|
||||
if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) {
|
||||
conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb)
|
||||
}
|
||||
if (conf.rcvConcurrency == def.rcvConcurrency) {
|
||||
conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency)
|
||||
}
|
||||
chatModel.controller.apiSetNetworkConfig(conf)
|
||||
chatModel.controller.setNetCfg(conf)
|
||||
networkUseSocksProxy.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.network_disable_socks),
|
||||
text = generalGetString(MR.strings.network_disable_socks_info),
|
||||
confirmText = generalGetString(MR.strings.confirm_verb),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
var conf = controller.getNetCfg().copy(socksProxy = null)
|
||||
if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) {
|
||||
conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout)
|
||||
}
|
||||
if (conf.tcpTimeout == proxyDef.tcpTimeout) {
|
||||
conf = conf.copy(tcpTimeout = def.tcpTimeout)
|
||||
}
|
||||
if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) {
|
||||
conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb)
|
||||
}
|
||||
if (conf.rcvConcurrency == proxyDef.rcvConcurrency) {
|
||||
conf = conf.copy(rcvConcurrency = def.rcvConcurrency)
|
||||
}
|
||||
chatModel.controller.apiSetNetworkConfig(conf)
|
||||
chatModel.controller.setNetCfg(conf)
|
||||
networkUseSocksProxy.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun NetworkAndServersLayout(
|
||||
currentRemoteHost: RemoteHostInfo?,
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
toggleSocksProxy: (Boolean) -> Unit,
|
||||
) {
|
||||
val m = chatModel
|
||||
val conditionsAction = remember { m.conditions.value.conditionsAction }
|
||||
val anyOperatorEnabled = remember { derivedStateOf { userServers.value.any { it.operator?.enabled == true } } }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (currUserServers.value.isNotEmpty() || userServers.value.isNotEmpty()) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
try {
|
||||
val servers = getUserServers(rh = currentRemoteHost?.remoteHostId)
|
||||
if (servers != null) {
|
||||
currUserServers.value = servers
|
||||
userServers.value = servers
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConditionsButton(conditionsAction: UsageConditionsAction, rhId: Long?) {
|
||||
SectionItemView(
|
||||
click = { ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> UsageConditionsView(currUserServers, userServers, close, rhId) } },
|
||||
) {
|
||||
Text(
|
||||
stringResource(if (conditionsAction is UsageConditionsAction.Review) MR.strings.operator_review_conditions else MR.strings.operator_conditions_accepted),
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ColumnWithScrollBar {
|
||||
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
|
||||
val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) }}
|
||||
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
|
||||
val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) } }
|
||||
|
||||
AppBarTitle(stringResource(MR.strings.network_and_servers))
|
||||
// TODO: Review this and socks.
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.message_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) } })
|
||||
SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) {
|
||||
userServers.value.forEachIndexed { index, srv ->
|
||||
srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, currentRemoteHost?.remoteHostId) }
|
||||
}
|
||||
}
|
||||
if (conditionsAction != null && anyOperatorEnabled.value) {
|
||||
ConditionsButton(conditionsAction, rhId = currentRemoteHost?.remoteHostId)
|
||||
}
|
||||
val footerText = if (conditionsAction is UsageConditionsAction.Review && conditionsAction.deadline != null && anyOperatorEnabled.value) {
|
||||
String.format(generalGetString(MR.strings.operator_conditions_will_be_accepted_on), localDate(conditionsAction.deadline))
|
||||
} else null
|
||||
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.media_and_file_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } })
|
||||
if (footerText != null) {
|
||||
SectionTextFooter(footerText)
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
if (currentRemoteHost == null) {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
||||
SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) }})
|
||||
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } })
|
||||
if (networkUseSocksProxy.value) {
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
} else {
|
||||
SectionDividerSpaced()
|
||||
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
|
||||
val nullOperatorIndex = userServers.value.indexOfFirst { it.operator == null }
|
||||
|
||||
if (nullOperatorIndex != -1) {
|
||||
SectionItemView({
|
||||
ModalManager.start.showModal {
|
||||
YourServersView(
|
||||
userServers = userServers,
|
||||
serverErrors = serverErrors,
|
||||
operatorIndex = nullOperatorIndex,
|
||||
rhId = currentRemoteHost?.remoteHostId
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_dns),
|
||||
stringResource(MR.strings.your_servers),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
TextIconSpaced()
|
||||
Text(stringResource(MR.strings.your_servers), color = MaterialTheme.colors.onBackground)
|
||||
|
||||
if (currUserServers.value.getOrNull(nullOperatorIndex) != userServers.value.getOrNull(nullOperatorIndex)) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
UnsavedChangesIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRemoteHost == null) {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
||||
SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) } })
|
||||
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } })
|
||||
if (networkUseSocksProxy.value) {
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
} else {
|
||||
SectionDividerSpaced(maxBottomPadding = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)
|
||||
|
||||
SectionItemView(
|
||||
{ scope.launch { saveServers(rhId = currentRemoteHost?.remoteHostId, currUserServers, userServers) } },
|
||||
disabled = saveDisabled,
|
||||
) {
|
||||
Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
}
|
||||
val serversErr = globalServersError(serverErrors.value)
|
||||
if (serversErr != null) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(serversErr)
|
||||
}
|
||||
} else if (serverErrors.value.isNotEmpty()) {
|
||||
SectionCustomFooter {
|
||||
ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration))
|
||||
}
|
||||
}
|
||||
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(generalGetString(MR.strings.settings_section_title_calls)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } })
|
||||
|
@ -504,6 +631,165 @@ fun showWrongProxyConfigAlert() {
|
|||
)
|
||||
}
|
||||
|
||||
@Composable()
|
||||
private fun ServerOperatorRow(
|
||||
index: Int,
|
||||
operator: ServerOperator,
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
rhId: Long?
|
||||
) {
|
||||
SectionItemView(
|
||||
{
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
OperatorView(
|
||||
currUserServers,
|
||||
userServers,
|
||||
serverErrors,
|
||||
index,
|
||||
rhId
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Image(
|
||||
painterResource(operator.logo),
|
||||
operator.tradeName,
|
||||
modifier = Modifier.size(24.dp),
|
||||
colorFilter = if (operator.enabled) null else ColorFilter.colorMatrix(ColorMatrix().apply {
|
||||
setToSaturation(0f)
|
||||
})
|
||||
)
|
||||
TextIconSpaced()
|
||||
Text(operator.tradeName, color = if (operator.enabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
|
||||
if (currUserServers.value.getOrNull(index) != userServers.value.getOrNull(index)) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
UnsavedChangesIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnsavedChangesIndicator() {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_edit_filled),
|
||||
stringResource(MR.strings.icon_descr_edited),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UsageConditionsView(
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
close: () -> Unit,
|
||||
rhId: Long?
|
||||
) {
|
||||
suspend fun acceptForOperators(rhId: Long?, operatorIds: List<Long>, close: () -> Unit) {
|
||||
try {
|
||||
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
|
||||
val r = chatController.acceptConditions(rhId, conditionsId, operatorIds) ?: return
|
||||
chatModel.conditions.value = r
|
||||
updateOperatorsConditionsAcceptance(currUserServers, r.serverOperators)
|
||||
updateOperatorsConditionsAcceptance(userServers, r.serverOperators)
|
||||
close()
|
||||
} catch (ex: Exception) {
|
||||
Log.e(TAG, ex.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AcceptConditionsButton(operatorIds: List<Long>, close: () -> Unit, bottomPadding: Dp = DEFAULT_PADDING * 2) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = bottomPadding), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
OnboardingActionButton(
|
||||
labelId = MR.strings.accept_conditions,
|
||||
onboarding = null,
|
||||
enabled = operatorIds.isNotEmpty(),
|
||||
onclick = {
|
||||
scope.launch {
|
||||
acceptForOperators(rhId, operatorIds, close)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) {
|
||||
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false)
|
||||
when (val conditionsAction = chatModel.conditions.value.conditionsAction) {
|
||||
is UsageConditionsAction.Review -> {
|
||||
if (conditionsAction.operators.isNotEmpty()) {
|
||||
ReadableText(MR.strings.operators_conditions_will_be_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ })
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
|
||||
ConditionsTextView(rhId)
|
||||
}
|
||||
AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, if (conditionsAction.deadline != null) DEFAULT_PADDING_HALF else DEFAULT_PADDING * 2)
|
||||
if (conditionsAction.deadline != null) {
|
||||
SectionTextFooter(
|
||||
text = AnnotatedString(String.format(generalGetString(MR.strings.operator_conditions_accepted_for_enabled_operators_on), localDate(conditionsAction.deadline))),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING))
|
||||
}
|
||||
}
|
||||
|
||||
is UsageConditionsAction.Accepted -> {
|
||||
if (conditionsAction.operators.isNotEmpty()) {
|
||||
ReadableText(MR.strings.operators_conditions_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ })
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
|
||||
ConditionsTextView(rhId)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) {
|
||||
ConditionsTextView(rhId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServersErrorFooter(errStr: String) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_error),
|
||||
contentDescription = stringResource(MR.strings.server_error),
|
||||
tint = Color.Red,
|
||||
modifier = Modifier
|
||||
.size(19.sp.toDp())
|
||||
.offset(x = 2.sp.toDp())
|
||||
)
|
||||
TextIconSpaced()
|
||||
Text(
|
||||
errStr,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
lineHeight = 18.sp,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(MR.strings.smp_save_servers_question),
|
||||
confirmText = generalGetString(MR.strings.save_verb),
|
||||
dismissText = generalGetString(MR.strings.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
|
||||
fun showUpdateNetworkSettingsDialog(
|
||||
title: String,
|
||||
startsWith: String = "",
|
||||
|
@ -521,6 +807,107 @@ fun showUpdateNetworkSettingsDialog(
|
|||
)
|
||||
}
|
||||
|
||||
fun updateOperatorsConditionsAcceptance(usvs: MutableState<List<UserOperatorServers>>, updatedOperators: List<ServerOperator>) {
|
||||
val modified = ArrayList(usvs.value)
|
||||
for (i in modified.indices) {
|
||||
val updatedOperator = updatedOperators.firstOrNull { it.operatorId == modified[i].operator?.operatorId } ?: continue
|
||||
modified[i] = modified[i].copy(operator = modified[i].operator?.copy(conditionsAcceptance = updatedOperator.conditionsAcceptance))
|
||||
}
|
||||
usvs.value = modified
|
||||
}
|
||||
|
||||
suspend fun validateServers_(
|
||||
rhId: Long?,
|
||||
userServersToValidate: List<UserOperatorServers>,
|
||||
serverErrors: MutableState<List<UserServersError>>
|
||||
) {
|
||||
try {
|
||||
val errors = chatController.validateServers(rhId, userServersToValidate) ?: return
|
||||
serverErrors.value = errors
|
||||
} catch (ex: Exception) {
|
||||
Log.e(TAG, ex.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
fun serversCanBeSaved(
|
||||
currUserServers: List<UserOperatorServers>,
|
||||
userServers: List<UserOperatorServers>,
|
||||
serverErrors: List<UserServersError>
|
||||
): Boolean {
|
||||
return userServers != currUserServers && serverErrors.isEmpty()
|
||||
}
|
||||
|
||||
fun globalServersError(serverErrors: List<UserServersError>): String? {
|
||||
for (err in serverErrors) {
|
||||
if (err.globalError != null) {
|
||||
return err.globalError
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun globalSMPServersError(serverErrors: List<UserServersError>): String? {
|
||||
for (err in serverErrors) {
|
||||
if (err.globalSMPError != null) {
|
||||
return err.globalSMPError
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun globalXFTPServersError(serverErrors: List<UserServersError>): String? {
|
||||
for (err in serverErrors) {
|
||||
if (err.globalXFTPError != null) {
|
||||
return err.globalXFTPError
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun findDuplicateHosts(serverErrors: List<UserServersError>): Set<String> {
|
||||
val duplicateHostsList = serverErrors.mapNotNull { err ->
|
||||
if (err is UserServersError.DuplicateServer) {
|
||||
err.duplicateHost
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return duplicateHostsList.toSet()
|
||||
}
|
||||
|
||||
private suspend fun saveServers(
|
||||
rhId: Long?,
|
||||
currUserServers: MutableState<List<UserOperatorServers>>,
|
||||
userServers: MutableState<List<UserOperatorServers>>
|
||||
) {
|
||||
val userServersToSave = userServers.value
|
||||
try {
|
||||
val set = setUserServers(rhId, userServersToSave)
|
||||
|
||||
if (set) {
|
||||
// Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers)
|
||||
val updatedServers = getUserServers(rhId)
|
||||
// Get updated operators to update model
|
||||
val updatedOperators = getServerOperators(rhId)
|
||||
|
||||
if (updatedOperators != null) {
|
||||
chatModel.conditions.value = updatedOperators
|
||||
}
|
||||
|
||||
if (updatedServers != null ) {
|
||||
currUserServers.value = updatedServers
|
||||
userServers.value = updatedServers
|
||||
} else {
|
||||
currUserServers.value = userServersToSave
|
||||
}
|
||||
} else {
|
||||
currUserServers.value = userServersToSave
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Log.e(TAG, ex.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewNetworkAndServersLayout() {
|
||||
|
@ -530,6 +917,9 @@ fun PreviewNetworkAndServersLayout() {
|
|||
networkUseSocksProxy = remember { mutableStateOf(true) },
|
||||
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
|
||||
toggleSocksProxy = {},
|
||||
currUserServers = remember { mutableStateOf(emptyList()) },
|
||||
userServers = remember { mutableStateOf(emptyList()) },
|
||||
serverErrors = remember { mutableStateOf(emptyList()) }
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,16 +1,14 @@
|
|||
package chat.simplex.common.views.usersettings
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
|
@ -26,62 +24,103 @@ import chat.simplex.common.views.helpers.*
|
|||
import chat.simplex.common.views.newchat.QRCode
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.usersettings.PreferenceToggle
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun ProtocolServerView(m: ChatModel, server: ServerCfg, serverProtocol: ServerProtocol, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ProtocolServerLayout(
|
||||
testing,
|
||||
server,
|
||||
serverProtocol,
|
||||
testServer = {
|
||||
testing = true
|
||||
withLongRunningApi {
|
||||
val res = testServerConnection(server, m)
|
||||
if (isActive) {
|
||||
onUpdate(res.first)
|
||||
testing = false
|
||||
fun ProtocolServerView(
|
||||
m: ChatModel,
|
||||
server: UserServer,
|
||||
serverProtocol: ServerProtocol,
|
||||
userServers: MutableState<List<UserOperatorServers>>,
|
||||
serverErrors: MutableState<List<UserServersError>>,
|
||||
onDelete: () -> Unit,
|
||||
onUpdate: (UserServer) -> Unit,
|
||||
close: () -> Unit,
|
||||
rhId: Long?
|
||||
) {
|
||||
val testing = remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val draftServer = remember { mutableStateOf(server) }
|
||||
|
||||
ModalView(
|
||||
close = {
|
||||
scope.launch {
|
||||
val draftResult = serverProtocolAndOperator(draftServer.value, userServers.value)
|
||||
val savedResult = serverProtocolAndOperator(server, userServers.value)
|
||||
|
||||
if (draftResult != null && savedResult != null) {
|
||||
val (serverToEditProtocol, serverToEditOperator) = draftResult
|
||||
val (svProtocol, serverOperator) = savedResult
|
||||
|
||||
if (serverToEditProtocol != svProtocol) {
|
||||
close()
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.error_updating_server_title),
|
||||
text = generalGetString(MR.strings.error_server_protocol_changed)
|
||||
)
|
||||
} else if (serverToEditOperator != serverOperator) {
|
||||
close()
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.error_updating_server_title),
|
||||
text = generalGetString(MR.strings.error_server_operator_changed)
|
||||
)
|
||||
} else {
|
||||
onUpdate(draftServer.value)
|
||||
close()
|
||||
}
|
||||
} else {
|
||||
close()
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.smp_servers_invalid_address),
|
||||
text = generalGetString(MR.strings.smp_servers_check_address)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdate,
|
||||
onDelete
|
||||
)
|
||||
if (testing) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 2.5.dp
|
||||
}
|
||||
) {
|
||||
Box {
|
||||
ProtocolServerLayout(
|
||||
draftServer,
|
||||
serverProtocol,
|
||||
testing.value,
|
||||
testServer = {
|
||||
testing.value = true
|
||||
withLongRunningApi {
|
||||
val res = testServerConnection(draftServer.value, m)
|
||||
if (isActive) {
|
||||
draftServer.value = res.first
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onDelete
|
||||
)
|
||||
|
||||
if (testing.value) {
|
||||
DefaultProgressView(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProtocolServerLayout(
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
server: MutableState<UserServer>,
|
||||
serverProtocol: ServerProtocol,
|
||||
testing: Boolean,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(if (server.preset) MR.strings.smp_servers_preset_server else MR.strings.smp_servers_your_server))
|
||||
AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.XFTP) MR.strings.xftp_server else MR.strings.smp_server))
|
||||
|
||||
if (server.preset) {
|
||||
PresetServer(testing, server, testServer, onUpdate, onDelete)
|
||||
if (server.value.preset) {
|
||||
PresetServer(server, testing, testServer)
|
||||
} else {
|
||||
CustomServer(testing, server, serverProtocol, testServer, onUpdate, onDelete)
|
||||
CustomServer(server, testing, testServer, onDelete)
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
|
@ -89,16 +128,14 @@ private fun ProtocolServerLayout(
|
|||
|
||||
@Composable
|
||||
private fun PresetServer(
|
||||
server: MutableState<UserServer>,
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
testServer: () -> Unit
|
||||
) {
|
||||
SectionView(stringResource(MR.strings.smp_servers_preset_address).uppercase()) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
server.server,
|
||||
server.value.server,
|
||||
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
|
||||
|
@ -108,23 +145,21 @@ private fun PresetServer(
|
|||
}
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
UseServerSection(true, testing, server, testServer, onUpdate, onDelete)
|
||||
UseServerSection(server, true, testing, testServer)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomServer(
|
||||
fun CustomServer(
|
||||
server: MutableState<UserServer>,
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
serverProtocol: ServerProtocol,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onDelete: (() -> Unit)?,
|
||||
) {
|
||||
val serverAddress = remember { mutableStateOf(server.server) }
|
||||
val serverAddress = remember { mutableStateOf(server.value.server) }
|
||||
val valid = remember {
|
||||
derivedStateOf {
|
||||
with(parseServerAddress(serverAddress.value)) {
|
||||
this?.valid == true && this.serverProtocol == serverProtocol
|
||||
this?.valid == true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -142,13 +177,14 @@ private fun CustomServer(
|
|||
snapshotFlow { serverAddress.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
testedPreviously[server.server] = server.tested
|
||||
onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value]))
|
||||
testedPreviously[server.value.server] = server.value.tested
|
||||
server.value = server.value.copy(server = it, tested = testedPreviously[serverAddress.value])
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete)
|
||||
|
||||
UseServerSection(server, valid.value, testing, testServer, onDelete)
|
||||
|
||||
if (valid.value) {
|
||||
SectionDividerSpaced()
|
||||
|
@ -160,43 +196,44 @@ private fun CustomServer(
|
|||
|
||||
@Composable
|
||||
private fun UseServerSection(
|
||||
server: MutableState<UserServer>,
|
||||
valid: Boolean,
|
||||
testing: Boolean,
|
||||
server: ServerCfg,
|
||||
testServer: () -> Unit,
|
||||
onUpdate: (ServerCfg) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
) {
|
||||
SectionView(stringResource(MR.strings.smp_servers_use_server).uppercase()) {
|
||||
SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) {
|
||||
Text(stringResource(MR.strings.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
|
||||
ShowTestStatus(server)
|
||||
ShowTestStatus(server.value)
|
||||
}
|
||||
|
||||
val enabled = rememberUpdatedState(server.enabled)
|
||||
val enabled = rememberUpdatedState(server.value.enabled)
|
||||
PreferenceToggle(
|
||||
stringResource(MR.strings.smp_servers_use_server_for_new_conn),
|
||||
disabled = server.tested != true && !server.preset,
|
||||
disabled = testing,
|
||||
checked = enabled.value
|
||||
) {
|
||||
onUpdate(server.copy(enabled = it))
|
||||
server.value = server.value.copy(enabled = it)
|
||||
}
|
||||
|
||||
SectionItemView(onDelete, disabled = testing) {
|
||||
Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error)
|
||||
|
||||
if (onDelete != null) {
|
||||
SectionItemView(onDelete, disabled = testing) {
|
||||
Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) =
|
||||
fun ShowTestStatus(server: UserServer, modifier: Modifier = Modifier) =
|
||||
when (server.tested) {
|
||||
true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen)
|
||||
false -> Icon(painterResource(MR.images.ic_close), null, modifier, tint = MaterialTheme.colors.error)
|
||||
else -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = Color.Transparent)
|
||||
}
|
||||
|
||||
suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair<ServerCfg, ProtocolTestFailure?> =
|
||||
suspend fun testServerConnection(server: UserServer, m: ChatModel): Pair<UserServer, ProtocolTestFailure?> =
|
||||
try {
|
||||
val r = m.controller.testProtoServer(server.remoteHostId, server.server)
|
||||
server.copy(tested = r == null) to r
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,29 +1,25 @@
|
|||
package chat.simplex.common.views.usersettings
|
||||
package chat.simplex.common.views.usersettings.networkAndServers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
|
||||
import chat.simplex.common.model.ServerCfg
|
||||
import chat.simplex.common.model.UserServer
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.QRCodeScanner
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
expect fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit)
|
||||
expect fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit)
|
||||
|
||||
@Composable
|
||||
fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) {
|
||||
fun ScanProtocolServerLayout(rhId: Long?, onNext: (UserServer) -> Unit) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr))
|
||||
QRCodeScanner { text ->
|
||||
val res = parseServerAddress(text)
|
||||
if (res != null) {
|
||||
onNext(ServerCfg(remoteHostId = rhId, text, false, null, false))
|
||||
onNext(UserServer(remoteHostId = rhId, null, text, false, null, false, false))
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.smp_servers_invalid_address),
|
|
@ -109,6 +109,16 @@
|
|||
<string name="failed_to_create_user_invalid_title">Invalid display name!</string>
|
||||
<string name="failed_to_create_user_invalid_desc">This display name is invalid. Please choose another name.</string>
|
||||
<string name="failed_to_active_user_title">Error switching profile!</string>
|
||||
<string name="failed_to_save_servers">Error saving servers</string>
|
||||
<string name="no_message_servers_configured">No message servers.</string>
|
||||
<string name="no_message_servers_configured_for_receiving">No servers to receive messages.</string>
|
||||
<string name="no_message_servers_configured_for_private_routing">No servers for private message routing.</string>
|
||||
<string name="no_media_servers_configured">No media & file servers.</string>
|
||||
<string name="no_media_servers_configured_for_sending">No servers to send files.</string>
|
||||
<string name="no_media_servers_configured_for_private_routing">No servers to receive files.</string>
|
||||
<string name="for_chat_profile">For chat profile %s:</string>
|
||||
<string name="errors_in_servers_configuration">Errors in servers configuration.</string>
|
||||
<string name="error_accepting_operator_conditions">Error accepting conditions</string>
|
||||
|
||||
<!-- API Error Responses - SimpleXAPI.kt -->
|
||||
<string name="connection_timeout">Connection timeout</string>
|
||||
|
@ -750,6 +760,7 @@
|
|||
<string name="smp_servers_test_some_failed">Some servers failed the test:</string>
|
||||
<string name="smp_servers_scan_qr">Scan server QR code</string>
|
||||
<string name="smp_servers_enter_manually">Enter server manually</string>
|
||||
<string name="smp_servers_new_server">New server</string>
|
||||
<string name="smp_servers_preset_server">Preset server</string>
|
||||
<string name="smp_servers_your_server">Your server</string>
|
||||
<string name="smp_servers_your_server_address">Your server address</string>
|
||||
|
@ -1038,6 +1049,19 @@
|
|||
<string name="you_can_change_it_later">Random passphrase is stored in settings as plaintext.\nYou can change it later.</string>
|
||||
<string name="use_random_passphrase">Use random passphrase</string>
|
||||
|
||||
<!-- ChooseServerOperators.kt -->
|
||||
<string name="onboarding_choose_server_operators">Choose operators</string>
|
||||
<string name="onboarding_network_operators">Network operators</string>
|
||||
<string name="onboarding_network_operators_app_will_use_different_operators">When more than one network operator is enabled, the app will use the servers of different operators for each conversation.</string>
|
||||
<string name="onboarding_network_operators_app_will_use_for_routing">For example, if you receive messages via SimpleX Chat server, the app will use one of Flux servers for private routing.</string>
|
||||
<string name="onboarding_select_network_operators_to_use">Select network operators to use.</string>
|
||||
<string name="onboarding_network_operators_configure_via_settings">You can configure servers via settings.</string>
|
||||
<string name="onboarding_network_operators_conditions_will_be_accepted">Conditions will be accepted for enabled operators after 30 days.</string>
|
||||
<string name="onboarding_network_operators_conditions_you_can_configure">You can configure operators in Network & servers settings.</string>
|
||||
<string name="onboarding_network_operators_review_later">Review later</string>
|
||||
<string name="onboarding_network_operators_update">Update</string>
|
||||
<string name="onboarding_network_operators_continue">Continue</string>
|
||||
|
||||
<!-- Call -->
|
||||
<string name="incoming_video_call">Incoming video call</string>
|
||||
<string name="incoming_audio_call">Incoming audio call</string>
|
||||
|
@ -1667,6 +1691,59 @@
|
|||
<string name="save_group_profile">Save group profile</string>
|
||||
<string name="error_saving_group_profile">Error saving group profile</string>
|
||||
|
||||
<!-- NetworkAndServers.kt -->
|
||||
<string name="network_preset_servers_title">Preset servers</string>
|
||||
<string name="operator_review_conditions">Review conditions</string>
|
||||
<string name="operator_conditions_accepted">Accepted conditions</string>
|
||||
<string name="operator_conditions_accepted_for_enabled_operators_on">Conditions will be automatically accepted for enabled operators on: %s.</string>
|
||||
<string name="your_servers">Your servers</string>
|
||||
<string name="operators_conditions_accepted_for"><![CDATA[Conditions are accepted for the operator(s): <b>%s</b>.]]></string>
|
||||
<string name="operators_conditions_will_be_accepted_for"><![CDATA[Conditions will be accepted for the operator(s): <b>%s</b>.]]></string>
|
||||
|
||||
<!-- OperatorView.kt -->
|
||||
<string name="operator">Operator</string>
|
||||
<string name="operator_servers_title">%s servers</string>
|
||||
<string name="operator_info_title">Network operator</string>
|
||||
<string name="operator_website">Website</string>
|
||||
<string name="operator_conditions_accepted_on">Conditions accepted on: %s.</string>
|
||||
<string name="operator_conditions_will_be_accepted_on">Conditions will be accepted on: %s.</string>
|
||||
<string name="operator">Operator</string>
|
||||
<string name="operator_use_operator_toggle_description">Use servers</string>
|
||||
<string name="use_servers_of_operator_x">Use %s</string>
|
||||
<string name="operator_conditions_failed_to_load">Current conditions text couldn\'t be loaded, you can review conditions via this link:</string>
|
||||
<string name="operator_conditions_accepted_for_some"><![CDATA[Conditions are already accepted for following operator(s): <b>%s</b>.]]></string>
|
||||
<string name="operator_same_conditions_will_be_applied"><![CDATA[Same conditions will apply to operator <b>%s</b>.]]></string>
|
||||
<string name="operator_same_conditions_will_apply_to_operators"><![CDATA[Same conditions will apply to operator(s): <b>%s</b>.]]></string>
|
||||
<string name="operator_conditions_accepted_for_some"><![CDATA[Conditions are already accepted for following operator(s): <b>%s</b>.]]></string>
|
||||
<string name="operator_conditions_will_be_applied"><![CDATA[These conditions will also apply for: <b>%s</b>.]]></string>
|
||||
<string name="operator_same_conditions_will_be_applied"><![CDATA[Same conditions will apply to operator: <b>%s</b>.]]></string>
|
||||
<string name="operator_conditions_will_be_accepted_for_some"><![CDATA[Conditions will be accepted for operator(s): <b>%s</b>.]]></string>
|
||||
<string name="operators_conditions_will_also_apply"><![CDATA[These conditions will also apply for: <b>%s</b>.]]></string>
|
||||
<string name="view_conditions">View conditions</string>
|
||||
<string name="accept_conditions">Accept conditions</string>
|
||||
<string name="operator_conditions_of_use">Conditions of use</string>
|
||||
<string name="operator_in_order_to_use_accept_conditions"><![CDATA[To use the servers of <b>%s</b>, accept conditions of use.]]></string>
|
||||
<string name="operator_use_for_messages">Use for messages</string>
|
||||
<string name="operator_use_for_messages_receiving">To receive</string>
|
||||
<string name="operator_use_for_messages_private_routing">For private routing</string>
|
||||
<string name="operator_added_message_servers">Added message servers</string>
|
||||
<string name="operator_use_for_files">Use for files</string>
|
||||
<string name="operator_use_for_sending">To send</string>
|
||||
<string name="xftp_servers_per_user">The servers for new files of your current chat profile</string>
|
||||
<string name="operator_added_xftp_servers">Added media & file servers</string>
|
||||
<string name="operator_open_conditions">Open conditions</string>
|
||||
<string name="operator_open_changes">Open changes</string>
|
||||
|
||||
<!-- ProtocolServerView.kt -->
|
||||
<string name="error_updating_server_title">Error updating server</string>
|
||||
<string name="error_server_protocol_changed">Server protocol changed.</string>
|
||||
<string name="error_server_operator_changed">Server operator changed.</string>
|
||||
|
||||
<!-- NewServerView.kt -->
|
||||
<string name="operator_server_alert_title">Operator server</string>
|
||||
<string name="server_added_to_operator__name">Server added to operator %s.</string>
|
||||
<string name="error_adding_server">Error adding server</string>
|
||||
|
||||
<!-- AdvancedNetworkSettings.kt -->
|
||||
<string name="network_option_tcp_connection">TCP connection</string>
|
||||
<string name="network_options_reset_to_defaults">Reset to defaults</string>
|
||||
|
@ -2059,6 +2136,13 @@
|
|||
<string name="v6_1_message_dates_descr">Better message dates.</string>
|
||||
<string name="v6_1_forward_many_messages_descr">Forward up to 20 messages at once.</string>
|
||||
<string name="v6_1_delete_many_messages_descr">Delete or moderate up to 200 messages.</string>
|
||||
<string name="v6_2_network_decentralization">Network decentralization</string>
|
||||
<string name="v6_2_network_decentralization_descr">The second preset operator in the app!</string>
|
||||
<string name="v6_2_network_decentralization_enable_flux">Enable flux</string>
|
||||
<string name="v6_2_network_decentralization_enable_flux_reason">for better metadata privacy.</string>
|
||||
<string name="v6_2_improved_chat_navigation">Improved chat navigation</string>
|
||||
<string name="v6_2_improved_chat_navigation_descr">- Open chat on the first unread message.\n- Jump to quoted messages.</string>
|
||||
<string name="view_updated_conditions">View updated conditions</string>
|
||||
|
||||
<!-- CustomTimePicker -->
|
||||
<string name="custom_time_unit_seconds">seconds</string>
|
||||
|
|
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 17 KiB |
|
@ -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 |
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
|||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: a64c1aa2c41938c5e18cc49d08075f14e5d25f0d
|
||||
tag: 97104988a307bd27b8bf5da7ed67455f3531d7ae
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
|
24
docs/rfcs/2024-10-27-server-operators.md
Normal 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.
|
|
@ -29,6 +29,7 @@ dependencies:
|
|||
- email-validate == 2.3.*
|
||||
- exceptions == 0.10.*
|
||||
- filepath == 1.4.*
|
||||
- file-embed == 0.0.15.*
|
||||
- http-types == 0.12.*
|
||||
- http2 >= 4.2.2 && < 4.3
|
||||
- memory == 0.18.*
|
||||
|
@ -38,6 +39,7 @@ dependencies:
|
|||
- optparse-applicative >= 0.15 && < 0.17
|
||||
- random >= 1.1 && < 1.3
|
||||
- record-hasfield == 1.0.*
|
||||
- scientific ==0.3.7.*
|
||||
- simple-logger == 0.1.*
|
||||
- simplexmq >= 5.0
|
||||
- socks == 0.6.*
|
||||
|
@ -72,7 +74,7 @@ when:
|
|||
- bytestring == 0.10.*
|
||||
- process >= 1.6 && < 1.6.18
|
||||
- template-haskell == 2.16.*
|
||||
- text >= 1.2.3.0 && < 1.3
|
||||
- text >= 1.2.4.0 && < 1.3
|
||||
|
||||
library:
|
||||
source-dirs: src
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."a64c1aa2c41938c5e18cc49d08075f14e5d25f0d" = "1kf86vrh5zfrqyczfjcj3d2nagmqb0rwhhdc10fw5n8jcgmdw6rp";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."97104988a307bd27b8bf5da7ed67455f3531d7ae" = "1xhk8cg4338d0cfjhdm2460p6nbvxfra80qnab2607nvy8wpddvl";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
|
|
|
@ -151,10 +151,13 @@ library
|
|||
Simplex.Chat.Migrations.M20241008_indexes
|
||||
Simplex.Chat.Migrations.M20241010_contact_requests_contact_id
|
||||
Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id
|
||||
Simplex.Chat.Migrations.M20241027_server_operators
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Mobile.File
|
||||
Simplex.Chat.Mobile.Shared
|
||||
Simplex.Chat.Mobile.WebRTC
|
||||
Simplex.Chat.Operators
|
||||
Simplex.Chat.Operators.Conditions
|
||||
Simplex.Chat.Options
|
||||
Simplex.Chat.ProfileGenerator
|
||||
Simplex.Chat.Protocol
|
||||
|
@ -214,6 +217,7 @@ library
|
|||
, directory ==1.3.*
|
||||
, email-validate ==2.3.*
|
||||
, exceptions ==0.10.*
|
||||
, file-embed ==0.0.15.*
|
||||
, filepath ==1.4.*
|
||||
, http-types ==0.12.*
|
||||
, http2 >=4.2.2 && <4.3
|
||||
|
@ -224,6 +228,7 @@ library
|
|||
, optparse-applicative >=0.15 && <0.17
|
||||
, random >=1.1 && <1.3
|
||||
, record-hasfield ==1.0.*
|
||||
, scientific ==0.3.7.*
|
||||
, simple-logger ==0.1.*
|
||||
, simplexmq >=5.0
|
||||
, socks ==0.6.*
|
||||
|
@ -250,7 +255,7 @@ library
|
|||
bytestring ==0.10.*
|
||||
, process >=1.6 && <1.6.18
|
||||
, template-haskell ==2.16.*
|
||||
, text >=1.2.3.0 && <1.3
|
||||
, text >=1.2.4.0 && <1.3
|
||||
|
||||
executable simplex-bot
|
||||
main-is: Main.hs
|
||||
|
@ -277,6 +282,7 @@ executable simplex-bot
|
|||
, directory ==1.3.*
|
||||
, email-validate ==2.3.*
|
||||
, exceptions ==0.10.*
|
||||
, file-embed ==0.0.15.*
|
||||
, filepath ==1.4.*
|
||||
, http-types ==0.12.*
|
||||
, http2 >=4.2.2 && <4.3
|
||||
|
@ -287,6 +293,7 @@ executable simplex-bot
|
|||
, optparse-applicative >=0.15 && <0.17
|
||||
, random >=1.1 && <1.3
|
||||
, record-hasfield ==1.0.*
|
||||
, scientific ==0.3.7.*
|
||||
, simple-logger ==0.1.*
|
||||
, simplex-chat
|
||||
, simplexmq >=5.0
|
||||
|
@ -314,7 +321,7 @@ executable simplex-bot
|
|||
bytestring ==0.10.*
|
||||
, process >=1.6 && <1.6.18
|
||||
, template-haskell ==2.16.*
|
||||
, text >=1.2.3.0 && <1.3
|
||||
, text >=1.2.4.0 && <1.3
|
||||
|
||||
executable simplex-bot-advanced
|
||||
main-is: Main.hs
|
||||
|
@ -341,6 +348,7 @@ executable simplex-bot-advanced
|
|||
, directory ==1.3.*
|
||||
, email-validate ==2.3.*
|
||||
, exceptions ==0.10.*
|
||||
, file-embed ==0.0.15.*
|
||||
, filepath ==1.4.*
|
||||
, http-types ==0.12.*
|
||||
, http2 >=4.2.2 && <4.3
|
||||
|
@ -351,6 +359,7 @@ executable simplex-bot-advanced
|
|||
, optparse-applicative >=0.15 && <0.17
|
||||
, random >=1.1 && <1.3
|
||||
, record-hasfield ==1.0.*
|
||||
, scientific ==0.3.7.*
|
||||
, simple-logger ==0.1.*
|
||||
, simplex-chat
|
||||
, simplexmq >=5.0
|
||||
|
@ -378,7 +387,7 @@ executable simplex-bot-advanced
|
|||
bytestring ==0.10.*
|
||||
, process >=1.6 && <1.6.18
|
||||
, template-haskell ==2.16.*
|
||||
, text >=1.2.3.0 && <1.3
|
||||
, text >=1.2.4.0 && <1.3
|
||||
|
||||
executable simplex-broadcast-bot
|
||||
main-is: Main.hs
|
||||
|
@ -408,6 +417,7 @@ executable simplex-broadcast-bot
|
|||
, directory ==1.3.*
|
||||
, email-validate ==2.3.*
|
||||
, exceptions ==0.10.*
|
||||
, file-embed ==0.0.15.*
|
||||
, filepath ==1.4.*
|
||||
, http-types ==0.12.*
|
||||
, http2 >=4.2.2 && <4.3
|
||||
|
@ -418,6 +428,7 @@ executable simplex-broadcast-bot
|
|||
, optparse-applicative >=0.15 && <0.17
|
||||
, random >=1.1 && <1.3
|
||||
, record-hasfield ==1.0.*
|
||||
, scientific ==0.3.7.*
|
||||
, simple-logger ==0.1.*
|
||||
, simplex-chat
|
||||
, simplexmq >=5.0
|
||||
|
@ -445,7 +456,7 @@ executable simplex-broadcast-bot
|
|||
bytestring ==0.10.*
|
||||
, process >=1.6 && <1.6.18
|
||||
, template-haskell ==2.16.*
|
||||
, text >=1.2.3.0 && <1.3
|
||||
, text >=1.2.4.0 && <1.3
|
||||
|
||||
executable simplex-chat
|
||||
main-is: Main.hs
|
||||
|
@ -473,6 +484,7 @@ executable simplex-chat
|
|||
, directory ==1.3.*
|
||||
, email-validate ==2.3.*
|
||||
, exceptions ==0.10.*
|
||||
, file-embed ==0.0.15.*
|
||||
, filepath ==1.4.*
|
||||
, http-types ==0.12.*
|
||||
, http2 >=4.2.2 && <4.3
|
||||
|
@ -483,6 +495,7 @@ executable simplex-chat
|
|||
, optparse-applicative >=0.15 && <0.17
|
||||
, random >=1.1 && <1.3
|
||||
, record-hasfield ==1.0.*
|
||||
, scientific ==0.3.7.*
|
||||
, simple-logger ==0.1.*
|
||||
, simplex-chat
|
||||
, simplexmq >=5.0
|
||||
|
@ -511,7 +524,7 @@ executable simplex-chat
|
|||
bytestring ==0.10.*
|
||||
, process >=1.6 && <1.6.18
|
||||
, template-haskell ==2.16.*
|
||||
, text >=1.2.3.0 && <1.3
|
||||
, text >=1.2.4.0 && <1.3
|
||||
|
||||
executable simplex-directory-service
|
||||
main-is: Main.hs
|
||||
|
@ -544,6 +557,7 @@ executable simplex-directory-service
|
|||
, directory ==1.3.*
|
||||
, email-validate ==2.3.*
|
||||
, exceptions ==0.10.*
|
||||
, file-embed ==0.0.15.*
|
||||
, filepath ==1.4.*
|
||||
, http-types ==0.12.*
|
||||
, http2 >=4.2.2 && <4.3
|
||||
|
@ -554,6 +568,7 @@ executable simplex-directory-service
|
|||
, optparse-applicative >=0.15 && <0.17
|
||||
, random >=1.1 && <1.3
|
||||
, record-hasfield ==1.0.*
|
||||
, scientific ==0.3.7.*
|
||||
, simple-logger ==0.1.*
|
||||
, simplex-chat
|
||||
, simplexmq >=5.0
|
||||
|
@ -581,7 +596,7 @@ executable simplex-directory-service
|
|||
bytestring ==0.10.*
|
||||
, process >=1.6 && <1.6.18
|
||||
, template-haskell ==2.16.*
|
||||
, text >=1.2.3.0 && <1.3
|
||||
, text >=1.2.4.0 && <1.3
|
||||
|
||||
test-suite simplex-chat-test
|
||||
type: exitcode-stdio-1.0
|
||||
|
@ -603,6 +618,7 @@ test-suite simplex-chat-test
|
|||
MarkdownTests
|
||||
MessageBatching
|
||||
MobileTests
|
||||
OperatorTests
|
||||
ProtocolTests
|
||||
RandomServers
|
||||
RemoteTests
|
||||
|
@ -643,6 +659,7 @@ test-suite simplex-chat-test
|
|||
, directory ==1.3.*
|
||||
, email-validate ==2.3.*
|
||||
, exceptions ==0.10.*
|
||||
, file-embed ==0.0.15.*
|
||||
, filepath ==1.4.*
|
||||
, generic-random ==1.5.*
|
||||
, http-types ==0.12.*
|
||||
|
@ -654,6 +671,7 @@ test-suite simplex-chat-test
|
|||
, optparse-applicative >=0.15 && <0.17
|
||||
, random >=1.1 && <1.3
|
||||
, record-hasfield ==1.0.*
|
||||
, scientific ==0.3.7.*
|
||||
, silently ==1.2.*
|
||||
, simple-logger ==0.1.*
|
||||
, simplex-chat
|
||||
|
@ -682,7 +700,7 @@ test-suite simplex-chat-test
|
|||
bytestring ==0.10.*
|
||||
, process >=1.6 && <1.6.18
|
||||
, template-haskell ==2.16.*
|
||||
, text >=1.2.3.0 && <1.3
|
||||
, text >=1.2.4.0 && <1.3
|
||||
if impl(ghc >= 9.6.2)
|
||||
build-depends:
|
||||
hspec ==2.11.*
|
||||
|
|
|
@ -3,9 +3,11 @@
|
|||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE GADTs #-}
|
||||
{-# LANGUAGE KindSignatures #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE MultiWayIf #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedLists #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE PatternSynonyms #-}
|
||||
{-# LANGUAGE RankNTypes #-}
|
||||
|
@ -43,7 +45,7 @@ import Data.Functor (($>))
|
|||
import Data.Functor.Identity
|
||||
import Data.Int (Int64)
|
||||
import Data.List (find, foldl', isSuffixOf, mapAccumL, partition, sortOn, zipWith4)
|
||||
import Data.List.NonEmpty (NonEmpty (..), nonEmpty, toList, (<|))
|
||||
import Data.List.NonEmpty (NonEmpty (..), (<|))
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as M
|
||||
|
@ -54,6 +56,7 @@ import qualified Data.Text as T
|
|||
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
|
||||
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
|
||||
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds)
|
||||
import Data.Type.Equality
|
||||
import qualified Data.UUID as UUID
|
||||
import qualified Data.UUID.V4 as V4
|
||||
import Data.Word (Word32)
|
||||
|
@ -67,6 +70,7 @@ import Simplex.Chat.Messages
|
|||
import Simplex.Chat.Messages.Batch (MsgBatch (..), batchMessages)
|
||||
import Simplex.Chat.Messages.CIContent
|
||||
import Simplex.Chat.Messages.CIContent.Events
|
||||
import Simplex.Chat.Operators
|
||||
import Simplex.Chat.Options
|
||||
import Simplex.Chat.ProfileGenerator (generateRandomProfile)
|
||||
import Simplex.Chat.Protocol
|
||||
|
@ -97,7 +101,7 @@ import qualified Simplex.FileTransfer.Transport as XFTP
|
|||
import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId)
|
||||
import Simplex.Messaging.Agent as Agent
|
||||
import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, getFastNetworkConfig, ipAddressProtected, withLockMap)
|
||||
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), createAgentStore, defaultAgentConfig, enabledServerCfg, presetServerCfg)
|
||||
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), ServerRoles (..), allRoles, createAgentStore, defaultAgentConfig)
|
||||
import Simplex.Messaging.Agent.Lock (withLock)
|
||||
import Simplex.Messaging.Agent.Protocol
|
||||
import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..))
|
||||
|
@ -137,6 +141,34 @@ import qualified UnliftIO.Exception as E
|
|||
import UnliftIO.IO (hClose, hSeek, hTell, openFile)
|
||||
import UnliftIO.STM
|
||||
|
||||
operatorSimpleXChat :: NewServerOperator
|
||||
operatorSimpleXChat =
|
||||
ServerOperator
|
||||
{ operatorId = DBNewEntity,
|
||||
operatorTag = Just OTSimplex,
|
||||
tradeName = "SimpleX Chat",
|
||||
legalName = Just "SimpleX Chat Ltd",
|
||||
serverDomains = ["simplex.im"],
|
||||
conditionsAcceptance = CARequired Nothing,
|
||||
enabled = True,
|
||||
smpRoles = allRoles,
|
||||
xftpRoles = allRoles
|
||||
}
|
||||
|
||||
operatorFlux :: NewServerOperator
|
||||
operatorFlux =
|
||||
ServerOperator
|
||||
{ operatorId = DBNewEntity,
|
||||
operatorTag = Just OTFlux,
|
||||
tradeName = "Flux",
|
||||
legalName = Just "InFlux Technologies Limited",
|
||||
serverDomains = ["simplexonflux.com"],
|
||||
conditionsAcceptance = CARequired Nothing,
|
||||
enabled = False,
|
||||
smpRoles = ServerRoles {storage = False, proxy = True},
|
||||
xftpRoles = allRoles
|
||||
}
|
||||
|
||||
defaultChatConfig :: ChatConfig
|
||||
defaultChatConfig =
|
||||
ChatConfig
|
||||
|
@ -147,13 +179,27 @@ defaultChatConfig =
|
|||
},
|
||||
chatVRange = supportedChatVRange,
|
||||
confirmMigrations = MCConsole,
|
||||
defaultServers =
|
||||
DefaultAgentServers
|
||||
{ smp = _defaultSMPServers,
|
||||
useSMP = 4,
|
||||
-- this property should NOT use operator = Nothing
|
||||
-- non-operator servers can be passed via options
|
||||
presetServers =
|
||||
PresetServers
|
||||
{ operators =
|
||||
[ PresetOperator
|
||||
{ operator = Just operatorSimpleXChat,
|
||||
smp = simplexChatSMPServers,
|
||||
useSMP = 4,
|
||||
xftp = map (presetServer True) $ L.toList defaultXFTPServers,
|
||||
useXFTP = 3
|
||||
},
|
||||
PresetOperator
|
||||
{ operator = Just operatorFlux,
|
||||
smp = fluxSMPServers,
|
||||
useSMP = 3,
|
||||
xftp = fluxXFTPServers,
|
||||
useXFTP = 3
|
||||
}
|
||||
],
|
||||
ntf = _defaultNtfServers,
|
||||
xftp = L.map (presetServerCfg True) defaultXFTPServers,
|
||||
useXFTP = L.length defaultXFTPServers,
|
||||
netCfg = defaultNetworkConfig
|
||||
},
|
||||
tbqSize = 1024,
|
||||
|
@ -177,29 +223,52 @@ defaultChatConfig =
|
|||
chatHooks = defaultChatHooks
|
||||
}
|
||||
|
||||
_defaultSMPServers :: NonEmpty (ServerCfg 'PSMP)
|
||||
_defaultSMPServers =
|
||||
L.fromList $
|
||||
map
|
||||
(presetServerCfg True)
|
||||
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
|
||||
"smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion",
|
||||
"smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion",
|
||||
"smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion",
|
||||
"smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion",
|
||||
"smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion",
|
||||
"smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion",
|
||||
"smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion",
|
||||
"smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion",
|
||||
"smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion",
|
||||
"smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion"
|
||||
simplexChatSMPServers :: [NewUserServer 'PSMP]
|
||||
simplexChatSMPServers =
|
||||
map
|
||||
(presetServer True)
|
||||
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion",
|
||||
"smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion",
|
||||
"smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion",
|
||||
"smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion",
|
||||
"smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion",
|
||||
"smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion",
|
||||
"smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion",
|
||||
"smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion",
|
||||
"smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion",
|
||||
"smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion",
|
||||
"smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion"
|
||||
]
|
||||
<> map
|
||||
(presetServer False)
|
||||
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion",
|
||||
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion",
|
||||
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion"
|
||||
]
|
||||
<> map
|
||||
(presetServerCfg False)
|
||||
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion",
|
||||
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion",
|
||||
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion"
|
||||
]
|
||||
|
||||
fluxSMPServers :: [NewUserServer 'PSMP]
|
||||
fluxSMPServers =
|
||||
map
|
||||
(presetServer True)
|
||||
[ "smp://xQW_ufMkGE20UrTlBl8QqceG1tbuylXhr9VOLPyRJmw=@smp1.simplexonflux.com,qb4yoanyl4p7o33yrknv4rs6qo7ugeb2tu2zo66sbebezs4cpyosarid.onion",
|
||||
"smp://LDnWZVlAUInmjmdpQQoIo6FUinRXGe0q3zi5okXDE4s=@smp2.simplexonflux.com,yiqtuh3q4x7hgovkomafsod52wvfjucdljqbbipg5sdssnklgongxbqd.onion",
|
||||
"smp://1jne379u7IDJSxAvXbWb_JgoE7iabcslX0LBF22Rej0=@smp3.simplexonflux.com,a5lm4k7ufei66cdck6fy63r4lmkqy3dekmmb7jkfdm5ivi6kfaojshad.onion",
|
||||
"smp://xmAmqj75I9mWrUihLUlI0ZuNLXlIwFIlHRq5Pb6cHAU=@smp4.simplexonflux.com,qpcz2axyy66u26hfdd2e23uohcf3y6c36mn7dcuilcgnwjasnrvnxjqd.onion",
|
||||
"smp://rWvBYyTamuRCBYb_KAn-nsejg879ndhiTg5Sq3k0xWA=@smp5.simplexonflux.com,4ao347qwiuluyd45xunmii4skjigzuuox53hpdsgbwxqafd4yrticead.onion",
|
||||
"smp://PN7-uqLBToqlf1NxHEaiL35lV2vBpXq8Nj8BW11bU48=@smp6.simplexonflux.com,hury6ot3ymebbr2535mlp7gcxzrjpc6oujhtfxcfh2m4fal4xw5fq6qd.onion"
|
||||
]
|
||||
|
||||
fluxXFTPServers :: [NewUserServer 'PXFTP]
|
||||
fluxXFTPServers =
|
||||
map
|
||||
(presetServer True)
|
||||
[ "xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com,apl3pumq3emwqtrztykyyoomdx4dg6ysql5zek2bi3rgznz7ai3odkid.onion",
|
||||
"xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com,c5jjecisncnngysah3cz2mppediutfelco4asx65mi75d44njvua3xid.onion",
|
||||
"xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com,dc4mohiubvbnsdfqqn7xhlhpqs5u4tjzp7xpz6v6corwvzvqjtaqqiqd.onion",
|
||||
"xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com,4qq5pzier3i4yhpuhcrhfbl6j25udc4czoyascrj4yswhodhfwev3nyd.onion",
|
||||
"xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com,q7itltdn32hjmgcqwhow4tay5ijetng3ur32bolssw32fvc5jrwvozad.onion",
|
||||
"xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com,upvzf23ou6nrmaf3qgnhd6cn3d74tvivlmz3p7wdfwq6fhthjrjiiqid.onion"
|
||||
]
|
||||
|
||||
_defaultNtfServers :: [NtfServer]
|
||||
_defaultNtfServers =
|
||||
|
@ -236,16 +305,23 @@ newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Boo
|
|||
newChatController
|
||||
ChatDatabase {chatStore, agentStore}
|
||||
user
|
||||
cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, deviceNameForRemote, confirmMigrations}
|
||||
cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations}
|
||||
ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable, yesToUpMigrations}, deviceName, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize}
|
||||
backgroundMode = do
|
||||
let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False}
|
||||
confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations
|
||||
config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'}
|
||||
config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'}
|
||||
firstTime = dbNew chatStore
|
||||
currentUser <- newTVarIO user
|
||||
randomPresetServers <- chooseRandomServers presetServers'
|
||||
let rndSrvs = L.toList randomPresetServers
|
||||
operatorWithId (i, op) = (\o -> o {operatorId = DBEntityId i}) <$> pOperator op
|
||||
opDomains = operatorDomains $ mapMaybe operatorWithId $ zip [1..] rndSrvs
|
||||
agentSMP <- randomServerCfgs "agent SMP servers" SPSMP opDomains rndSrvs
|
||||
agentXFTP <- randomServerCfgs "agent XFTP servers" SPXFTP opDomains rndSrvs
|
||||
let randomAgentServers = RandomAgentServers {smpServers = agentSMP, xftpServers = agentXFTP}
|
||||
currentRemoteHost <- newTVarIO Nothing
|
||||
servers <- agentServers config
|
||||
servers <- withTransaction chatStore $ \db -> agentServers db config randomPresetServers randomAgentServers
|
||||
smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode
|
||||
agentAsync <- newTVarIO Nothing
|
||||
random <- liftIO C.newRandom
|
||||
|
@ -281,6 +357,8 @@ newChatController
|
|||
ChatController
|
||||
{ firstTime,
|
||||
currentUser,
|
||||
randomPresetServers,
|
||||
randomAgentServers,
|
||||
currentRemoteHost,
|
||||
smpAgent,
|
||||
agentAsync,
|
||||
|
@ -318,28 +396,48 @@ newChatController
|
|||
contactMergeEnabled
|
||||
}
|
||||
where
|
||||
configServers :: DefaultAgentServers
|
||||
configServers =
|
||||
let DefaultAgentServers {smp = defSmp, xftp = defXftp, netCfg} = defaultServers
|
||||
smp' = maybe defSmp (L.map enabledServerCfg) (nonEmpty smpServers)
|
||||
xftp' = maybe defXftp (L.map enabledServerCfg) (nonEmpty xftpServers)
|
||||
in defaultServers {smp = smp', xftp = xftp', netCfg = updateNetworkConfig netCfg simpleNetCfg}
|
||||
agentServers :: ChatConfig -> IO InitialAgentServers
|
||||
agentServers config@ChatConfig {defaultServers = defServers@DefaultAgentServers {ntf, netCfg}} = do
|
||||
users <- withTransaction chatStore getUsers
|
||||
smp' <- getUserServers users SPSMP
|
||||
xftp' <- getUserServers users SPXFTP
|
||||
pure InitialAgentServers {smp = smp', xftp = xftp', ntf, netCfg}
|
||||
presetServers' :: PresetServers
|
||||
presetServers' = presetServers {operators = operators', netCfg = netCfg'}
|
||||
where
|
||||
getUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => [User] -> SProtocolType p -> IO (Map UserId (NonEmpty (ServerCfg p)))
|
||||
getUserServers users protocol = case users of
|
||||
[] -> pure $ M.fromList [(1, cfgServers protocol defServers)]
|
||||
_ -> M.fromList <$> initialServers
|
||||
where
|
||||
initialServers :: IO [(UserId, NonEmpty (ServerCfg p))]
|
||||
initialServers = mapM (\u -> (aUserId u,) <$> userServers u) users
|
||||
userServers :: User -> IO (NonEmpty (ServerCfg p))
|
||||
userServers user' = useServers config protocol <$> withTransaction chatStore (`getProtocolServers` user')
|
||||
PresetServers {operators, netCfg} = presetServers
|
||||
netCfg' = updateNetworkConfig netCfg simpleNetCfg
|
||||
operators' = case (smpServers, xftpServers) of
|
||||
([], []) -> operators
|
||||
(smpSrvs, []) -> L.map disableSMP operators <> [custom smpSrvs []]
|
||||
([], xftpSrvs) -> L.map disableXFTP operators <> [custom [] xftpSrvs]
|
||||
(smpSrvs, xftpSrvs) -> [custom smpSrvs xftpSrvs]
|
||||
disableSMP op@PresetOperator {smp} = (op :: PresetOperator) {smp = map disableSrv smp}
|
||||
disableXFTP op@PresetOperator {xftp} = (op :: PresetOperator) {xftp = map disableSrv xftp}
|
||||
disableSrv :: forall p. NewUserServer p -> NewUserServer p
|
||||
disableSrv srv = (srv :: NewUserServer p) {enabled = False}
|
||||
custom smpSrvs xftpSrvs =
|
||||
PresetOperator
|
||||
{ operator = Nothing,
|
||||
smp = map newUserServer smpSrvs,
|
||||
useSMP = 0,
|
||||
xftp = map newUserServer xftpSrvs,
|
||||
useXFTP = 0
|
||||
}
|
||||
randomServerCfgs :: UserProtocol p => String -> SProtocolType p -> [(Text, ServerOperator)] -> [PresetOperator] -> IO (NonEmpty (ServerCfg p))
|
||||
randomServerCfgs name p opDomains rndSrvs =
|
||||
toJustOrError name $ L.nonEmpty $ agentServerCfgs p opDomains $ concatMap (pServers p) rndSrvs
|
||||
agentServers :: DB.Connection -> ChatConfig -> NonEmpty PresetOperator -> RandomAgentServers -> IO InitialAgentServers
|
||||
agentServers db ChatConfig {presetServers = PresetServers {ntf, netCfg}} presetOps as = do
|
||||
users <- getUsers db
|
||||
ops <- getUpdateServerOperators db presetOps (null users)
|
||||
let opDomains = operatorDomains $ mapMaybe snd ops
|
||||
(smp', xftp') <- unzip <$> mapM (getServers ops opDomains) users
|
||||
pure InitialAgentServers {smp = M.fromList smp', xftp = M.fromList xftp', ntf, netCfg}
|
||||
where
|
||||
getServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [(Text, ServerOperator)] -> User -> IO ((UserId, NonEmpty (ServerCfg 'PSMP)), (UserId, NonEmpty (ServerCfg 'PXFTP)))
|
||||
getServers ops opDomains user' = do
|
||||
smpSrvs <- getProtocolServers db SPSMP user'
|
||||
xftpSrvs <- getProtocolServers db SPXFTP user'
|
||||
uss <- groupByOperator' (ops, smpSrvs, xftpSrvs)
|
||||
ts <- getCurrentTime
|
||||
uss' <- mapM (setUserServers' db user' ts . updatedUserServers) uss
|
||||
let auId = aUserId user'
|
||||
pure $ bimap (auId,) (auId,) $ useServers as opDomains uss'
|
||||
|
||||
updateNetworkConfig :: NetworkConfig -> SimpleNetCfg -> NetworkConfig
|
||||
updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} =
|
||||
|
@ -382,33 +480,47 @@ withFileLock :: String -> Int64 -> CM a -> CM a
|
|||
withFileLock name = withEntityLock name . CLFile
|
||||
{-# INLINE withFileLock #-}
|
||||
|
||||
useServers :: UserProtocol p => ChatConfig -> SProtocolType p -> [ServerCfg p] -> NonEmpty (ServerCfg p)
|
||||
useServers ChatConfig {defaultServers} p = fromMaybe (cfgServers p defaultServers) . nonEmpty
|
||||
useServers :: Foldable f => RandomAgentServers -> [(Text, ServerOperator)] -> f UserOperatorServers -> (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))
|
||||
useServers as opDomains uss =
|
||||
let smp' = useServerCfgs SPSMP as opDomains $ concatMap (servers' SPSMP) uss
|
||||
xftp' = useServerCfgs SPXFTP as opDomains $ concatMap (servers' SPXFTP) uss
|
||||
in (smp', xftp')
|
||||
|
||||
randomServers :: forall p. UserProtocol p => SProtocolType p -> ChatConfig -> IO (NonEmpty (ServerCfg p), [ServerCfg p])
|
||||
randomServers p ChatConfig {defaultServers} = do
|
||||
let srvs = cfgServers p defaultServers
|
||||
(enbldSrvs, dsbldSrvs) = L.partition (\ServerCfg {enabled} -> enabled) srvs
|
||||
toUse = cfgServersToUse p defaultServers
|
||||
if length enbldSrvs <= toUse
|
||||
then pure (srvs, [])
|
||||
else do
|
||||
(enbldSrvs', srvsToDisable) <- splitAt toUse <$> shuffle enbldSrvs
|
||||
let dsbldSrvs' = map (\srv -> (srv :: ServerCfg p) {enabled = False}) srvsToDisable
|
||||
srvs' = sortOn server' $ enbldSrvs' <> dsbldSrvs' <> dsbldSrvs
|
||||
pure (fromMaybe srvs $ L.nonEmpty srvs', srvs')
|
||||
useServerCfgs :: forall p. UserProtocol p => SProtocolType p -> RandomAgentServers -> [(Text, ServerOperator)] -> [UserServer p] -> NonEmpty (ServerCfg p)
|
||||
useServerCfgs p RandomAgentServers {smpServers, xftpServers} opDomains =
|
||||
fromMaybe (rndAgentServers p) . L.nonEmpty . agentServerCfgs p opDomains
|
||||
where
|
||||
server' ServerCfg {server = ProtoServerWithAuth srv _} = srv
|
||||
rndAgentServers :: SProtocolType p -> NonEmpty (ServerCfg p)
|
||||
rndAgentServers = \case
|
||||
SPSMP -> smpServers
|
||||
SPXFTP -> xftpServers
|
||||
|
||||
cfgServers :: UserProtocol p => SProtocolType p -> DefaultAgentServers -> NonEmpty (ServerCfg p)
|
||||
cfgServers p DefaultAgentServers {smp, xftp} = case p of
|
||||
SPSMP -> smp
|
||||
SPXFTP -> xftp
|
||||
chooseRandomServers :: PresetServers -> IO (NonEmpty PresetOperator)
|
||||
chooseRandomServers PresetServers {operators} =
|
||||
forM operators $ \op -> do
|
||||
smp' <- opSrvs SPSMP op
|
||||
xftp' <- opSrvs SPXFTP op
|
||||
pure (op :: PresetOperator) {smp = smp', xftp = xftp'}
|
||||
where
|
||||
opSrvs :: forall p. UserProtocol p => SProtocolType p -> PresetOperator -> IO [NewUserServer p]
|
||||
opSrvs p op = do
|
||||
let srvs = pServers p op
|
||||
toUse = operatorServersToUse p op
|
||||
(enbldSrvs, dsbldSrvs) = partition (\UserServer {enabled} -> enabled) srvs
|
||||
if toUse <= 0 || toUse >= length enbldSrvs
|
||||
then pure srvs
|
||||
else do
|
||||
(enbldSrvs', srvsToDisable) <- splitAt toUse <$> shuffle enbldSrvs
|
||||
let dsbldSrvs' = map (\srv -> (srv :: NewUserServer p) {enabled = False}) srvsToDisable
|
||||
pure $ sortOn server' $ enbldSrvs' <> dsbldSrvs' <> dsbldSrvs
|
||||
server' UserServer {server = ProtoServerWithAuth srv _} = srv
|
||||
|
||||
cfgServersToUse :: UserProtocol p => SProtocolType p -> DefaultAgentServers -> Int
|
||||
cfgServersToUse p DefaultAgentServers {useSMP, useXFTP} = case p of
|
||||
SPSMP -> useSMP
|
||||
SPXFTP -> useXFTP
|
||||
toJustOrError :: String -> Maybe a -> IO a
|
||||
toJustOrError name = \case
|
||||
Just a -> pure a
|
||||
Nothing -> do
|
||||
putStrLn $ name <> ": expected Just, exiting"
|
||||
E.throwIO $ userError name
|
||||
|
||||
-- enableSndFiles has no effect when mainApp is True
|
||||
startChatController :: Bool -> Bool -> CM' (Async ())
|
||||
|
@ -438,7 +550,7 @@ startChatController mainApp enableSndFiles = do
|
|||
startXFTP startWorkers = do
|
||||
tmp <- readTVarIO =<< asks tempDirectory
|
||||
runExceptT (withAgent $ \a -> startWorkers a tmp) >>= \case
|
||||
Left e -> liftIO $ print $ "Error starting XFTP workers: " <> show e
|
||||
Left e -> liftIO $ putStrLn $ "Error starting XFTP workers: " <> show e
|
||||
Right _ -> pure ()
|
||||
startCleanupManager = do
|
||||
cleanupAsync <- asks cleanupManagerAsync
|
||||
|
@ -552,39 +664,43 @@ processChatCommand' vr = \case
|
|||
forM_ profile $ \Profile {displayName} -> checkValidName displayName
|
||||
p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile
|
||||
u <- asks currentUser
|
||||
(smp, smpServers) <- chooseServers SPSMP
|
||||
(xftp, xftpServers) <- chooseServers SPXFTP
|
||||
users <- withFastStore' getUsers
|
||||
forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash} ->
|
||||
when (n == displayName) . throwChatError $
|
||||
if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""}
|
||||
auId <- withAgent (\a -> createUser a smp xftp)
|
||||
(uss, (smp', xftp')) <- chooseServers =<< readTVarIO u
|
||||
auId <- withAgent $ \a -> createUser a smp' xftp'
|
||||
ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure
|
||||
user <- withFastStore $ \db -> createUserRecordAt db (AgentUserId auId) p True ts
|
||||
createPresetContactCards user `catchChatError` \_ -> pure ()
|
||||
withFastStore $ \db -> createNoteFolder db user
|
||||
storeServers user smpServers
|
||||
storeServers user xftpServers
|
||||
user <- withFastStore $ \db -> do
|
||||
user <- createUserRecordAt db (AgentUserId auId) p True ts
|
||||
mapM_ (setUserServers db user ts) uss
|
||||
createPresetContactCards db user `catchStoreError` \_ -> pure ()
|
||||
createNoteFolder db user
|
||||
pure user
|
||||
atomically . writeTVar u $ Just user
|
||||
pure $ CRActiveUser user
|
||||
where
|
||||
createPresetContactCards :: User -> CM ()
|
||||
createPresetContactCards user =
|
||||
withFastStore $ \db -> do
|
||||
createContact db user simplexStatusContactProfile
|
||||
createContact db user simplexTeamContactProfile
|
||||
chooseServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> CM (NonEmpty (ServerCfg p), [ServerCfg p])
|
||||
chooseServers protocol =
|
||||
asks currentUser >>= readTVarIO >>= \case
|
||||
Nothing -> asks config >>= liftIO . randomServers protocol
|
||||
Just user -> chosenServers =<< withFastStore' (`getProtocolServers` user)
|
||||
where
|
||||
chosenServers servers = do
|
||||
cfg <- asks config
|
||||
pure (useServers cfg protocol servers, servers)
|
||||
storeServers user servers =
|
||||
unless (null servers) . withFastStore $
|
||||
\db -> overwriteProtocolServers db user servers
|
||||
createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO ()
|
||||
createPresetContactCards db user = do
|
||||
createContact db user simplexStatusContactProfile
|
||||
createContact db user simplexTeamContactProfile
|
||||
chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP)))
|
||||
chooseServers user_ = do
|
||||
as <- asks randomAgentServers
|
||||
mapM (withFastStore . flip getUserServers >=> liftIO . groupByOperator) user_ >>= \case
|
||||
Just uss -> do
|
||||
let opDomains = operatorDomains $ mapMaybe operator' uss
|
||||
uss' = map copyServers uss
|
||||
pure $ (uss',) $ useServers as opDomains uss
|
||||
Nothing -> do
|
||||
ps <- asks randomPresetServers
|
||||
uss <- presetUserServers <$> withFastStore' (\db -> getUpdateServerOperators db ps True)
|
||||
let RandomAgentServers {smpServers = smp', xftpServers = xftp'} = as
|
||||
pure (uss, (smp', xftp'))
|
||||
copyServers :: UserOperatorServers -> UpdatedUserOperatorServers
|
||||
copyServers UserOperatorServers {operator, smpServers, xftpServers} =
|
||||
let new srv = AUS SDBNew srv {serverId = DBNewEntity}
|
||||
in UpdatedUserOperatorServers {operator, smpServers = map new smpServers, xftpServers = map new xftpServers}
|
||||
coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day)
|
||||
day = 86400
|
||||
ListUsers -> CRUsersList <$> withFastStore' getUsersInfo
|
||||
|
@ -1482,25 +1598,96 @@ processChatCommand' vr = \case
|
|||
msgs <- lift $ withAgent' $ \a -> getConnectionMessages a acIds
|
||||
let ntfMsgs = L.map (\msg -> receivedMsgInfo <$> msg) msgs
|
||||
pure $ CRConnNtfMessages ntfMsgs
|
||||
APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do
|
||||
cfg@ChatConfig {defaultServers} <- asks config
|
||||
servers <- withFastStore' (`getProtocolServers` user)
|
||||
pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p (useServers cfg p servers) (cfgServers p defaultServers)
|
||||
GetUserProtoServers aProtocol -> withUser $ \User {userId} ->
|
||||
processChatCommand $ APIGetUserProtoServers userId aProtocol
|
||||
APISetUserProtoServers userId (APSC p (ProtoServersConfig servers))
|
||||
| null servers || any (\ServerCfg {enabled} -> enabled) servers -> withUserId userId $ \user -> withServerProtocol p $ do
|
||||
withFastStore $ \db -> overwriteProtocolServers db user servers
|
||||
cfg <- asks config
|
||||
lift $ withAgent' $ \a -> setProtocolServers a (aUserId user) $ useServers cfg p servers
|
||||
ok user
|
||||
| otherwise -> withUserId userId $ \user -> pure $ chatCmdError (Just user) "all servers are disabled"
|
||||
SetUserProtoServers serversConfig -> withUser $ \User {userId} ->
|
||||
processChatCommand $ APISetUserProtoServers userId serversConfig
|
||||
GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do
|
||||
srvs <- withFastStore (`getUserServers` user)
|
||||
liftIO $ CRUserServers user <$> groupByOperator (protocolServers p srvs)
|
||||
SetUserProtoServers (AProtocolType (p :: SProtocolType p)) srvs -> withUser $ \user@User {userId} -> withServerProtocol p $ do
|
||||
userServers_ <- liftIO . groupByOperator =<< withFastStore (`getUserServers` user)
|
||||
case L.nonEmpty userServers_ of
|
||||
Nothing -> throwChatError $ CECommandError "no servers"
|
||||
Just userServers -> case srvs of
|
||||
[] -> throwChatError $ CECommandError "no servers"
|
||||
_ -> do
|
||||
srvs' <- mapM aUserServer srvs
|
||||
processChatCommand $ APISetUserServers userId $ L.map (updatedServers p srvs') userServers
|
||||
where
|
||||
aUserServer :: AProtoServerWithAuth -> CM (AUserServer p)
|
||||
aUserServer (AProtoServerWithAuth p' srv) = case testEquality p p' of
|
||||
Just Refl -> pure $ AUS SDBNew $ newUserServer srv
|
||||
Nothing -> throwChatError $ CECommandError $ "incorrect server protocol: " <> B.unpack (strEncode srv)
|
||||
APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user ->
|
||||
lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a (aUserId user) server)
|
||||
TestProtoServer srv -> withUser $ \User {userId} ->
|
||||
processChatCommand $ APITestProtoServer userId srv
|
||||
APIGetServerOperators -> CRServerOperatorConditions <$> withFastStore getServerOperators
|
||||
APISetServerOperators operators -> do
|
||||
as <- asks randomAgentServers
|
||||
(opsConds, srvs) <- withFastStore $ \db -> do
|
||||
liftIO $ setServerOperators db operators
|
||||
opsConds <- getServerOperators db
|
||||
let ops = serverOperators opsConds
|
||||
ops' = map Just ops <> [Nothing]
|
||||
opDomains = operatorDomains ops
|
||||
liftIO $ fmap (opsConds,) . mapM (getServers db as ops' opDomains) =<< getUsers db
|
||||
lift $ withAgent' $ \a -> forM_ srvs $ \(auId, (smp', xftp')) -> do
|
||||
setProtocolServers a auId smp'
|
||||
setProtocolServers a auId xftp'
|
||||
pure $ CRServerOperatorConditions opsConds
|
||||
where
|
||||
getServers :: DB.Connection -> RandomAgentServers -> [Maybe ServerOperator] -> [(Text, ServerOperator)] -> User -> IO (UserId, (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP)))
|
||||
getServers db as ops opDomains user = do
|
||||
smpSrvs <- getProtocolServers db SPSMP user
|
||||
xftpSrvs <- getProtocolServers db SPXFTP user
|
||||
uss <- groupByOperator (ops, smpSrvs, xftpSrvs)
|
||||
pure $ (aUserId user,) $ useServers as opDomains uss
|
||||
SetServerOperators operatorsRoles -> do
|
||||
ops <- serverOperators <$> withFastStore getServerOperators
|
||||
ops' <- mapM (updateOp ops) operatorsRoles
|
||||
processChatCommand $ APISetServerOperators ops'
|
||||
where
|
||||
updateOp :: [ServerOperator] -> ServerOperatorRoles -> CM ServerOperator
|
||||
updateOp ops r =
|
||||
case find (\ServerOperator {operatorId = DBEntityId opId} -> operatorId' r == opId) ops of
|
||||
Just op -> pure op {enabled = enabled' r, smpRoles = smpRoles' r, xftpRoles = xftpRoles' r}
|
||||
Nothing -> throwError $ ChatErrorStore $ SEOperatorNotFound $ operatorId' r
|
||||
APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> do
|
||||
CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user)
|
||||
APISetUserServers userId userServers -> withUserId userId $ \user -> do
|
||||
errors <- validateAllUsersServers userId $ L.toList userServers
|
||||
unless (null errors) $ throwChatError (CECommandError $ "user servers validation error(s): " <> show errors)
|
||||
uss <- withFastStore $ \db -> do
|
||||
ts <- liftIO getCurrentTime
|
||||
mapM (setUserServers db user ts) userServers
|
||||
as <- asks randomAgentServers
|
||||
lift $ withAgent' $ \a -> do
|
||||
let auId = aUserId user
|
||||
opDomains = operatorDomains $ mapMaybe operator' $ L.toList uss
|
||||
(smp', xftp') = useServers as opDomains uss
|
||||
setProtocolServers a auId smp'
|
||||
setProtocolServers a auId xftp'
|
||||
ok_
|
||||
APIValidateServers userId userServers -> withUserId userId $ \user ->
|
||||
CRUserServersValidation user <$> validateAllUsersServers userId userServers
|
||||
APIGetUsageConditions -> do
|
||||
(usageConditions, acceptedConditions) <- withFastStore $ \db -> do
|
||||
usageConditions <- getCurrentUsageConditions db
|
||||
acceptedConditions <- liftIO $ getLatestAcceptedConditions db
|
||||
pure (usageConditions, acceptedConditions)
|
||||
-- TODO if db commit is different from source commit, conditionsText should be nothing in response
|
||||
pure
|
||||
CRUsageConditions
|
||||
{ usageConditions,
|
||||
conditionsText = usageConditionsText,
|
||||
acceptedConditions
|
||||
}
|
||||
APISetConditionsNotified condId -> do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
withFastStore' $ \db -> setConditionsNotified db condId currentTs
|
||||
ok_
|
||||
APIAcceptConditions condId opIds -> withFastStore $ \db -> do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
acceptConditions db condId opIds currentTs
|
||||
CRServerOperatorConditions <$> getServerOperators db
|
||||
APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user ->
|
||||
checkStoreNotChanged $
|
||||
withChatLock "setChatItemTTL" $ do
|
||||
|
@ -1753,8 +1940,9 @@ processChatCommand' vr = \case
|
|||
canKeepLink (CRInvitationUri crData _) newUser = do
|
||||
let ConnReqUriData {crSmpQueues = q :| _} = crData
|
||||
SMPQueueUri {queueAddress = SMPQueueAddress {smpServer}} = q
|
||||
cfg <- asks config
|
||||
newUserServers <- L.map (\ServerCfg {server} -> protoServer server) . useServers cfg SPSMP <$> withFastStore' (`getProtocolServers` newUser)
|
||||
newUserServers <-
|
||||
map protoServer' . L.filter (\ServerCfg {enabled} -> enabled)
|
||||
<$> getKnownAgentServers SPSMP newUser
|
||||
pure $ smpServer `elem` newUserServers
|
||||
updateConnRecord user@User {userId} conn@PendingContactConnection {customUserProfileId} newUser = do
|
||||
withAgent $ \a -> changeConnectionUser a (aUserId user) (aConnId' conn) (aUserId newUser)
|
||||
|
@ -2088,7 +2276,7 @@ processChatCommand' vr = \case
|
|||
where
|
||||
changeMemberRole user gInfo members m gEvent = do
|
||||
let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m
|
||||
assertUserGroupRole gInfo $ maximum [GRAdmin, mRole, memRole]
|
||||
assertUserGroupRole gInfo $ maximum ([GRAdmin, mRole, memRole] :: [GroupMemberRole])
|
||||
withGroupLock "memberRole" groupId . procCmd $ do
|
||||
unless (mRole == memRole) $ do
|
||||
withFastStore' $ \db -> updateGroupMemberRole db user m memRole
|
||||
|
@ -2486,14 +2674,15 @@ processChatCommand' vr = \case
|
|||
pure $ CRAgentSubsTotal user subsTotal hasSession
|
||||
GetAgentServersSummary userId -> withUserId userId $ \user -> do
|
||||
agentServersSummary <- lift $ withAgent' getAgentServersSummary
|
||||
cfg <- asks config
|
||||
(users, smpServers, xftpServers) <-
|
||||
withStore' $ \db -> (,,) <$> getUsers db <*> getServers db cfg user SPSMP <*> getServers db cfg user SPXFTP
|
||||
let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers _defaultNtfServers
|
||||
pure $ CRAgentServersSummary user presentedServersSummary
|
||||
withStore' $ \db -> do
|
||||
users <- getUsers db
|
||||
smpServers <- getServers db user SPSMP
|
||||
xftpServers <- getServers db user SPXFTP
|
||||
let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers _defaultNtfServers
|
||||
pure $ CRAgentServersSummary user presentedServersSummary
|
||||
where
|
||||
getServers :: (ProtocolTypeI p, UserProtocol p) => DB.Connection -> ChatConfig -> User -> SProtocolType p -> IO (NonEmpty (ProtocolServer p))
|
||||
getServers db cfg user p = L.map (\ServerCfg {server} -> protoServer server) . useServers cfg p <$> getProtocolServers db user
|
||||
getServers :: ProtocolTypeI p => DB.Connection -> User -> SProtocolType p -> IO [ProtocolServer p]
|
||||
getServers db user p = map (\UserServer {server} -> protoServer server) <$> getProtocolServers db p user
|
||||
ResetAgentServersStats -> withAgent resetAgentServersStats >> ok_
|
||||
GetAgentWorkers -> lift $ CRAgentWorkersSummary <$> withAgent' getAgentWorkersSummary
|
||||
GetAgentWorkersDetails -> lift $ CRAgentWorkersDetails <$> withAgent' getAgentWorkersDetails
|
||||
|
@ -2784,6 +2973,20 @@ processChatCommand' vr = \case
|
|||
withServerProtocol p action = case userProtocol p of
|
||||
Just Dict -> action
|
||||
_ -> throwChatError $ CEServerProtocol $ AProtocolType p
|
||||
validateAllUsersServers :: UserServersClass u => Int64 -> [u] -> CM [UserServersError]
|
||||
validateAllUsersServers currUserId userServers = withFastStore $ \db -> do
|
||||
users' <- filter (\User {userId} -> userId /= currUserId) <$> liftIO (getUsers db)
|
||||
others <- mapM (getUserOperatorServers db) users'
|
||||
pure $ validateUserServers userServers others
|
||||
where
|
||||
getUserOperatorServers :: DB.Connection -> User -> ExceptT StoreError IO (User, [UserOperatorServers])
|
||||
getUserOperatorServers db user = do
|
||||
uss <- liftIO . groupByOperator =<< getUserServers db user
|
||||
pure (user, map updatedUserSrvs uss)
|
||||
updatedUserSrvs uss = uss {operator = updatedOp <$> operator' uss} :: UserOperatorServers
|
||||
updatedOp op = fromMaybe op $ find matchingOp $ mapMaybe operator' userServers
|
||||
where
|
||||
matchingOp op' = operatorId op' == operatorId op
|
||||
forwardFile :: ChatName -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> CM ChatResponse
|
||||
forwardFile chatName fileId sendCommand = withUser $ \user -> do
|
||||
withStore (\db -> getFileTransfer db user fileId) >>= \case
|
||||
|
@ -3225,6 +3428,23 @@ processChatCommand' vr = \case
|
|||
msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId)
|
||||
CRQueueInfo user msgInfo <$> withAgent (`getConnectionQueueInfo` acId)
|
||||
|
||||
protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP])
|
||||
protocolServers p (operators, smpServers, xftpServers) = case p of
|
||||
SPSMP -> (operators, smpServers, [])
|
||||
SPXFTP -> (operators, [], xftpServers)
|
||||
|
||||
-- disable preset and replace custom servers (groupByOperator always adds custom)
|
||||
updatedServers :: forall p. UserProtocol p => SProtocolType p -> [AUserServer p] -> UserOperatorServers -> UpdatedUserOperatorServers
|
||||
updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = case p' of
|
||||
SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers)
|
||||
SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers)
|
||||
where
|
||||
u = uncurry $ UpdatedUserOperatorServers operator
|
||||
updateSrvs :: [UserServer p] -> [AUserServer p]
|
||||
updateSrvs pSrvs = map disableSrv pSrvs <> maybe srvs (const []) operator
|
||||
disableSrv srv@UserServer {preset} =
|
||||
AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True}
|
||||
|
||||
type ComposeMessageReq = (ComposedMessage, Maybe CIForwardedFrom)
|
||||
|
||||
contactCITimed :: Contact -> CM (Maybe CITimed)
|
||||
|
@ -3611,8 +3831,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete}
|
|||
S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks
|
||||
getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer]
|
||||
getUnknownSrvs srvs = do
|
||||
cfg <- asks config
|
||||
knownSrvs <- L.map (\ServerCfg {server} -> protoServer server) . useServers cfg SPXFTP <$> withStore' (`getProtocolServers` user)
|
||||
knownSrvs <- L.map protoServer' <$> getKnownAgentServers SPXFTP user
|
||||
pure $ filter (`notElem` knownSrvs) srvs
|
||||
ipProtectedForSrvs :: [XFTPServer] -> CM Bool
|
||||
ipProtectedForSrvs srvs = do
|
||||
|
@ -3626,6 +3845,17 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete}
|
|||
toView $ CRChatItemUpdated user aci
|
||||
throwChatError $ CEFileNotApproved fileId unknownSrvs
|
||||
|
||||
getKnownAgentServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> User -> CM (NonEmpty (ServerCfg p))
|
||||
getKnownAgentServers p user = do
|
||||
as <- asks randomAgentServers
|
||||
withStore $ \db -> do
|
||||
opDomains <- operatorDomains . serverOperators <$> getServerOperators db
|
||||
srvs <- liftIO $ getProtocolServers db p user
|
||||
pure $ useServerCfgs p as opDomains srvs
|
||||
|
||||
protoServer' :: ServerCfg p -> ProtocolServer p
|
||||
protoServer' ServerCfg {server} = protoServer server
|
||||
|
||||
getNetworkConfig :: CM' NetworkConfig
|
||||
getNetworkConfig = withAgent' $ liftIO . getFastNetworkConfig
|
||||
|
||||
|
@ -3824,7 +4054,7 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do
|
|||
(sftConns, sfts) <- getSndFileTransferConns
|
||||
(rftConns, rfts) <- getRcvFileTransferConns
|
||||
(pcConns, pcs) <- getPendingContactConns
|
||||
let conns = concat [ctConns, ucConns, mConns, sftConns, rftConns, pcConns]
|
||||
let conns = concat ([ctConns, ucConns, mConns, sftConns, rftConns, pcConns] :: [[ConnId]])
|
||||
pure (conns, cts, ucs, gs, ms, sfts, rfts, pcs)
|
||||
-- subscribe using batched commands
|
||||
rs <- withAgent $ \a -> agentBatchSubscribe a conns
|
||||
|
@ -4632,7 +4862,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
|||
ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct)
|
||||
SWITCH qd phase cStats -> do
|
||||
toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats)
|
||||
when (phase `elem` [SPStarted, SPCompleted]) $ case qd of
|
||||
when (phase == SPStarted || phase == SPCompleted) $ case qd of
|
||||
QDRcv -> createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCESwitchQueue phase Nothing) Nothing
|
||||
QDSnd -> createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing
|
||||
RSYNC rss cryptoErr_ cStats ->
|
||||
|
@ -4917,7 +5147,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
|||
(Just fileDescrText, Just msgId) -> do
|
||||
partSize <- asks $ xftpDescrPartSize . config
|
||||
let parts = splitFileDescr partSize fileDescrText
|
||||
pure . toList $ L.map (XMsgFileDescr msgId) parts
|
||||
pure . L.toList $ L.map (XMsgFileDescr msgId) parts
|
||||
_ -> pure []
|
||||
let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents
|
||||
GroupMember {memberId} = sender
|
||||
|
@ -5043,7 +5273,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
|||
when continued $ sendPendingGroupMessages user m conn
|
||||
SWITCH qd phase cStats -> do
|
||||
toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats)
|
||||
when (phase `elem` [SPStarted, SPCompleted]) $ case qd of
|
||||
when (phase == SPStarted || phase == SPCompleted) $ case qd of
|
||||
QDRcv -> createInternalChatItem user (CDGroupSnd gInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing
|
||||
QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing
|
||||
RSYNC rss cryptoErr_ cStats ->
|
||||
|
@ -6607,15 +6837,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
|||
messageWarning "x.grp.mem.con: neither member is invitee"
|
||||
where
|
||||
inviteeXGrpMemCon :: GroupMemberIntro -> CM ()
|
||||
inviteeXGrpMemCon GroupMemberIntro {introId, introStatus}
|
||||
| introStatus == GMIntroReConnected = updateStatus introId GMIntroConnected
|
||||
| introStatus `elem` [GMIntroToConnected, GMIntroConnected] = pure ()
|
||||
| otherwise = updateStatus introId GMIntroToConnected
|
||||
inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of
|
||||
GMIntroReConnected -> updateStatus introId GMIntroConnected
|
||||
GMIntroToConnected -> pure ()
|
||||
GMIntroConnected -> pure ()
|
||||
_ -> updateStatus introId GMIntroToConnected
|
||||
forwardMemberXGrpMemCon :: GroupMemberIntro -> CM ()
|
||||
forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus}
|
||||
| introStatus == GMIntroToConnected = updateStatus introId GMIntroConnected
|
||||
| introStatus `elem` [GMIntroReConnected, GMIntroConnected] = pure ()
|
||||
| otherwise = updateStatus introId GMIntroReConnected
|
||||
forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of
|
||||
GMIntroToConnected -> updateStatus introId GMIntroConnected
|
||||
GMIntroReConnected -> pure ()
|
||||
GMIntroConnected -> pure ()
|
||||
_ -> updateStatus introId GMIntroReConnected
|
||||
updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status
|
||||
|
||||
xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> CM ()
|
||||
|
@ -8080,14 +8312,19 @@ chatCommandP =
|
|||
"/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP),
|
||||
"/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP),
|
||||
"/ntf test " *> (TestProtoServer . AProtoServerWithAuth SPNTF <$> strP),
|
||||
"/_servers " *> (APISetUserProtoServers <$> A.decimal <* A.space <*> srvCfgP),
|
||||
"/smp " *> (SetUserProtoServers . APSC SPSMP . ProtoServersConfig . map enabledServerCfg <$> protocolServersP),
|
||||
"/smp default" $> SetUserProtoServers (APSC SPSMP $ ProtoServersConfig []),
|
||||
"/xftp " *> (SetUserProtoServers . APSC SPXFTP . ProtoServersConfig . map enabledServerCfg <$> protocolServersP),
|
||||
"/xftp default" $> SetUserProtoServers (APSC SPXFTP $ ProtoServersConfig []),
|
||||
"/_servers " *> (APIGetUserProtoServers <$> A.decimal <* A.space <*> strP),
|
||||
"/smp " *> (SetUserProtoServers (AProtocolType SPSMP) . map (AProtoServerWithAuth SPSMP) <$> protocolServersP),
|
||||
"/xftp " *> (SetUserProtoServers (AProtocolType SPXFTP) . map (AProtoServerWithAuth SPXFTP) <$> protocolServersP),
|
||||
"/smp" $> GetUserProtoServers (AProtocolType SPSMP),
|
||||
"/xftp" $> GetUserProtoServers (AProtocolType SPXFTP),
|
||||
"/_operators" $> APIGetServerOperators,
|
||||
"/_operators " *> (APISetServerOperators <$> jsonP),
|
||||
"/operators " *> (SetServerOperators . L.fromList <$> operatorRolesP `A.sepBy1` A.char ','),
|
||||
"/_servers " *> (APIGetUserServers <$> A.decimal),
|
||||
"/_servers " *> (APISetUserServers <$> A.decimal <* A.space <*> jsonP),
|
||||
"/_validate_servers " *> (APIValidateServers <$> A.decimal <* A.space <*> jsonP),
|
||||
"/_conditions" $> APIGetUsageConditions,
|
||||
"/_conditions_notified " *> (APISetConditionsNotified <$> A.decimal),
|
||||
"/_accept_conditions " *> (APIAcceptConditions <$> A.decimal <*> _strP),
|
||||
"/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal),
|
||||
"/ttl " *> (SetChatItemTTL <$> ciTTL),
|
||||
"/_ttl " *> (APIGetChatItemTTL <$> A.decimal),
|
||||
|
@ -8411,6 +8648,20 @@ chatCommandP =
|
|||
optional ("yes" *> A.space) *> (TMEEnableSetTTL <$> timedTTLP)
|
||||
<|> ("yes" $> TMEEnableKeepTTL)
|
||||
<|> ("no" $> TMEDisableKeepTTL)
|
||||
operatorRolesP = do
|
||||
operatorId' <- A.decimal
|
||||
enabled' <- A.char ':' *> onOffP
|
||||
smpRoles' <- (":smp=" *> srvRolesP) <|> pure allRoles
|
||||
xftpRoles' <- (":xftp=" *> srvRolesP) <|> pure allRoles
|
||||
pure ServerOperatorRoles {operatorId', enabled', smpRoles', xftpRoles'}
|
||||
srvRolesP = srvRoles <$?> A.takeTill (\c -> c == ':' || c == ',')
|
||||
where
|
||||
srvRoles = \case
|
||||
"off" -> Right $ ServerRoles False False
|
||||
"proxy" -> Right ServerRoles {storage = False, proxy = True}
|
||||
"storage" -> Right ServerRoles {storage = True, proxy = False}
|
||||
"on" -> Right allRoles
|
||||
_ -> Left "bad ServerRoles"
|
||||
netCfgP = do
|
||||
socksProxy <- "socks=" *> ("off" $> Nothing <|> "on" $> Just defaultSocksProxyWithAuth <|> Just <$> strP)
|
||||
socksMode <- " socks-mode=" *> strP <|> pure SMAlways
|
||||
|
@ -8431,7 +8682,6 @@ chatCommandP =
|
|||
onOffP
|
||||
(Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP)))
|
||||
(pure Nothing)
|
||||
srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP)
|
||||
rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (jsonP <|> text1P))
|
||||
text1P = safeDecodeUtf8 <$> A.takeTill (== ' ')
|
||||
char_ = optional . A.char
|
||||
|
|
|
@ -35,7 +35,6 @@ import qualified Data.ByteArray as BA
|
|||
import Data.ByteString.Char8 (ByteString)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.Char (ord)
|
||||
import Data.Constraint (Dict (..))
|
||||
import Data.Int (Int64)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import Data.Map.Strict (Map)
|
||||
|
@ -57,6 +56,7 @@ import Simplex.Chat.Call
|
|||
import Simplex.Chat.Markdown (MarkdownList)
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Messages.CIContent
|
||||
import Simplex.Chat.Operators
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Remote.AppVersion
|
||||
import Simplex.Chat.Remote.Types
|
||||
|
@ -84,7 +84,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption)
|
|||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON)
|
||||
import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), ProtocolTypeI, QueueId, SMPMsgMeta (..), SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol)
|
||||
import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer)
|
||||
import Simplex.Messaging.TMap (TMap)
|
||||
import Simplex.Messaging.Transport (TLS, simplexMQVersion)
|
||||
import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost)
|
||||
|
@ -132,7 +132,7 @@ data ChatConfig = ChatConfig
|
|||
{ agentConfig :: AgentConfig,
|
||||
chatVRange :: VersionRangeChat,
|
||||
confirmMigrations :: MigrationConfirmation,
|
||||
defaultServers :: DefaultAgentServers,
|
||||
presetServers :: PresetServers,
|
||||
tbqSize :: Natural,
|
||||
fileChunkSize :: Integer,
|
||||
xftpDescrPartSize :: Int,
|
||||
|
@ -154,6 +154,12 @@ data ChatConfig = ChatConfig
|
|||
chatHooks :: ChatHooks
|
||||
}
|
||||
|
||||
data RandomAgentServers = RandomAgentServers
|
||||
{ smpServers :: NonEmpty (ServerCfg 'PSMP),
|
||||
xftpServers :: NonEmpty (ServerCfg 'PXFTP)
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
-- The hooks can be used to extend or customize chat core in mobile or CLI clients.
|
||||
data ChatHooks = ChatHooks
|
||||
{ -- preCmdHook can be used to process or modify the commands before they are processed.
|
||||
|
@ -172,14 +178,12 @@ defaultChatHooks =
|
|||
eventHook = \_ -> pure
|
||||
}
|
||||
|
||||
data DefaultAgentServers = DefaultAgentServers
|
||||
{ smp :: NonEmpty (ServerCfg 'PSMP),
|
||||
useSMP :: Int,
|
||||
data PresetServers = PresetServers
|
||||
{ operators :: NonEmpty PresetOperator,
|
||||
ntf :: [NtfServer],
|
||||
xftp :: NonEmpty (ServerCfg 'PXFTP),
|
||||
useXFTP :: Int,
|
||||
netCfg :: NetworkConfig
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
data InlineFilesConfig = InlineFilesConfig
|
||||
{ offerChunks :: Integer,
|
||||
|
@ -203,6 +207,8 @@ data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLite
|
|||
|
||||
data ChatController = ChatController
|
||||
{ currentUser :: TVar (Maybe User),
|
||||
randomPresetServers :: NonEmpty PresetOperator,
|
||||
randomAgentServers :: RandomAgentServers,
|
||||
currentRemoteHost :: TVar (Maybe RemoteHostId),
|
||||
firstTime :: Bool,
|
||||
smpAgent :: AgentClient,
|
||||
|
@ -346,12 +352,19 @@ data ChatCommand
|
|||
| APIGetGroupLink GroupId
|
||||
| APICreateMemberContact GroupId GroupMemberId
|
||||
| APISendMemberContactInvitation {contactId :: ContactId, msgContent_ :: Maybe MsgContent}
|
||||
| APIGetUserProtoServers UserId AProtocolType
|
||||
| GetUserProtoServers AProtocolType
|
||||
| APISetUserProtoServers UserId AProtoServersConfig
|
||||
| SetUserProtoServers AProtoServersConfig
|
||||
| SetUserProtoServers AProtocolType [AProtoServerWithAuth]
|
||||
| APITestProtoServer UserId AProtoServerWithAuth
|
||||
| TestProtoServer AProtoServerWithAuth
|
||||
| APIGetServerOperators
|
||||
| APISetServerOperators (NonEmpty ServerOperator)
|
||||
| SetServerOperators (NonEmpty ServerOperatorRoles)
|
||||
| APIGetUserServers UserId
|
||||
| APISetUserServers UserId (NonEmpty UpdatedUserOperatorServers)
|
||||
| APIValidateServers UserId [UpdatedUserOperatorServers] -- response is CRUserServersValidation
|
||||
| APIGetUsageConditions
|
||||
| APISetConditionsNotified Int64
|
||||
| APIAcceptConditions Int64 (NonEmpty Int64)
|
||||
| APISetChatItemTTL UserId (Maybe Int64)
|
||||
| SetChatItemTTL (Maybe Int64)
|
||||
| APIGetChatItemTTL UserId
|
||||
|
@ -577,8 +590,11 @@ data ChatResponse
|
|||
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
|
||||
| CRChatItemId User (Maybe ChatItemId)
|
||||
| CRApiParsedMarkdown {formattedText :: Maybe MarkdownList}
|
||||
| CRUserProtoServers {user :: User, servers :: AUserProtoServers}
|
||||
| CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure}
|
||||
| CRServerOperatorConditions {conditions :: ServerOperatorConditions}
|
||||
| CRUserServers {user :: User, userServers :: [UserOperatorServers]}
|
||||
| CRUserServersValidation {user :: User, serverErrors :: [UserServersError]}
|
||||
| CRUsageConditions {usageConditions :: UsageConditions, conditionsText :: Text, acceptedConditions :: Maybe UsageConditions}
|
||||
| CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64}
|
||||
| CRNetworkConfig {networkConfig :: NetworkConfig}
|
||||
| CRContactInfo {user :: User, contact :: Contact, connectionStats_ :: Maybe ConnectionStats, customUserProfile :: Maybe Profile}
|
||||
|
@ -943,24 +959,6 @@ instance ToJSON AgentQueueId where
|
|||
toJSON = strToJSON
|
||||
toEncoding = strToJEncoding
|
||||
|
||||
data ProtoServersConfig p = ProtoServersConfig {servers :: [ServerCfg p]}
|
||||
deriving (Show)
|
||||
|
||||
data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) (ProtoServersConfig p)
|
||||
|
||||
deriving instance Show AProtoServersConfig
|
||||
|
||||
data UserProtoServers p = UserProtoServers
|
||||
{ serverProtocol :: SProtocolType p,
|
||||
protoServers :: NonEmpty (ServerCfg p),
|
||||
presetServers :: NonEmpty (ServerCfg p)
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p)
|
||||
|
||||
deriving instance Show AUserProtoServers
|
||||
|
||||
data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath}
|
||||
deriving (Show)
|
||||
|
||||
|
@ -1562,28 +1560,28 @@ $(JQ.deriveJSON defaultJSON ''CoreVersionInfo)
|
|||
|
||||
$(JQ.deriveJSON defaultJSON ''SlowSQLQuery)
|
||||
|
||||
instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where
|
||||
parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig)
|
||||
-- instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where
|
||||
-- parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig)
|
||||
|
||||
instance ProtocolTypeI p => FromJSON (UserProtoServers p) where
|
||||
parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers)
|
||||
-- instance ProtocolTypeI p => FromJSON (UserProtoServers p) where
|
||||
-- parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers)
|
||||
|
||||
instance ProtocolTypeI p => ToJSON (UserProtoServers p) where
|
||||
toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers)
|
||||
toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers)
|
||||
-- instance ProtocolTypeI p => ToJSON (UserProtoServers p) where
|
||||
-- toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers)
|
||||
-- toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers)
|
||||
|
||||
instance FromJSON AUserProtoServers where
|
||||
parseJSON v = J.withObject "AUserProtoServers" parse v
|
||||
where
|
||||
parse o = do
|
||||
AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol"
|
||||
case userProtocol p of
|
||||
Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v
|
||||
Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p
|
||||
-- instance FromJSON AUserProtoServers where
|
||||
-- parseJSON v = J.withObject "AUserProtoServers" parse v
|
||||
-- where
|
||||
-- parse o = do
|
||||
-- AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol"
|
||||
-- case userProtocol p of
|
||||
-- Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v
|
||||
-- Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p
|
||||
|
||||
instance ToJSON AUserProtoServers where
|
||||
toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s
|
||||
toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s
|
||||
-- instance ToJSON AUserProtoServers where
|
||||
-- toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s
|
||||
-- toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s
|
||||
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState)
|
||||
|
||||
|
|
58
src/Simplex/Chat/Migrations/M20241027_server_operators.hs
Normal 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,%";
|
||||
|]
|
|
@ -589,6 +589,35 @@ CREATE TABLE note_folders(
|
|||
unread_chat INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE app_settings(app_settings TEXT NOT NULL);
|
||||
CREATE TABLE server_operators(
|
||||
server_operator_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_operator_tag TEXT,
|
||||
trade_name TEXT NOT NULL,
|
||||
legal_name TEXT,
|
||||
server_domains TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
smp_role_storage INTEGER NOT NULL DEFAULT 1,
|
||||
smp_role_proxy INTEGER NOT NULL DEFAULT 1,
|
||||
xftp_role_storage INTEGER NOT NULL DEFAULT 1,
|
||||
xftp_role_proxy INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE usage_conditions(
|
||||
usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conditions_commit TEXT NOT NULL UNIQUE,
|
||||
notified_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE operator_usage_conditions(
|
||||
operator_usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_operator_id INTEGER REFERENCES server_operators(server_operator_id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
server_operator_tag TEXT,
|
||||
conditions_commit TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE INDEX contact_profiles_index ON contact_profiles(
|
||||
display_name,
|
||||
full_name
|
||||
|
@ -890,3 +919,10 @@ CREATE INDEX idx_received_probes_group_member_id on received_probes(
|
|||
group_member_id
|
||||
);
|
||||
CREATE INDEX idx_contact_requests_contact_id ON contact_requests(contact_id);
|
||||
CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_conditions(
|
||||
server_operator_id
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions(
|
||||
conditions_commit,
|
||||
server_operator_id
|
||||
);
|
||||
|
|
523
src/Simplex/Chat/Operators.hs
Normal 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)
|
19
src/Simplex/Chat/Operators/Conditions.hs
Normal 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`)
|
|
@ -236,7 +236,7 @@ coreChatOptsP appDir defaultDbFileName = do
|
|||
)
|
||||
yesToUpMigrations <-
|
||||
switch
|
||||
( long "--yes-migrate"
|
||||
( long "yes-migrate"
|
||||
<> short 'y'
|
||||
<> help "Automatically confirm \"up\" database migrations"
|
||||
)
|
||||
|
|
|
@ -7,7 +7,6 @@ module Simplex.Chat.Stats where
|
|||
|
||||
import qualified Data.Aeson.TH as J
|
||||
import Data.List (partition)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (fromMaybe, isJust)
|
||||
|
@ -131,7 +130,7 @@ data NtfServerSummary = NtfServerSummary
|
|||
-- - users are passed to exclude hidden users from totalServersSummary;
|
||||
-- - if currentUser is hidden, it should be accounted in totalServersSummary;
|
||||
-- - known is set only in user level summaries based on passed userSMPSrvs and userXFTPSrvs
|
||||
toPresentedServersSummary :: AgentServersSummary -> [User] -> User -> NonEmpty SMPServer -> NonEmpty XFTPServer -> [NtfServer] -> PresentedServersSummary
|
||||
toPresentedServersSummary :: AgentServersSummary -> [User] -> User -> [SMPServer] -> [XFTPServer] -> [NtfServer] -> PresentedServersSummary
|
||||
toPresentedServersSummary agentSummary users currentUser userSMPSrvs userXFTPSrvs userNtfSrvs = do
|
||||
let (userSMPSrvsSumms, allSMPSrvsSumms) = accSMPSrvsSummaries
|
||||
(userSMPCurr, userSMPPrev, userSMPProx) = smpSummsIntoCategories userSMPSrvsSumms
|
||||
|
|
|
@ -115,6 +115,7 @@ import Simplex.Chat.Migrations.M20240920_user_order
|
|||
import Simplex.Chat.Migrations.M20241008_indexes
|
||||
import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id
|
||||
import Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id
|
||||
import Simplex.Chat.Migrations.M20241027_server_operators
|
||||
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||
|
@ -229,7 +230,8 @@ schemaMigrations =
|
|||
("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order),
|
||||
("20241008_indexes", m20241008_indexes, Just down_m20241008_indexes),
|
||||
("20241010_contact_requests_contact_id", m20241010_contact_requests_contact_id, Just down_m20241010_contact_requests_contact_id),
|
||||
("20241023_chat_item_autoincrement_id", m20241023_chat_item_autoincrement_id, Just down_m20241023_chat_item_autoincrement_id)
|
||||
("20241023_chat_item_autoincrement_id", m20241023_chat_item_autoincrement_id, Just down_m20241023_chat_item_autoincrement_id),
|
||||
("20241027_server_operators", m20241027_server_operators, Just down_m20241027_server_operators)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE DeriveAnyClass #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE GADTs #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
@ -47,7 +50,17 @@ module Simplex.Chat.Store.Profiles
|
|||
getContactWithoutConnViaAddress,
|
||||
updateUserAddressAutoAccept,
|
||||
getProtocolServers,
|
||||
overwriteProtocolServers,
|
||||
insertProtocolServer,
|
||||
getUpdateServerOperators,
|
||||
getServerOperators,
|
||||
getUserServers,
|
||||
setServerOperators,
|
||||
getCurrentUsageConditions,
|
||||
getLatestAcceptedConditions,
|
||||
setConditionsNotified,
|
||||
acceptConditions,
|
||||
setUserServers,
|
||||
setUserServers',
|
||||
createCall,
|
||||
deleteCalls,
|
||||
getCalls,
|
||||
|
@ -68,14 +81,16 @@ import Data.Functor (($>))
|
|||
import Data.Int (Int64)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Maybe (catMaybes, fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
|
||||
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
||||
import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..))
|
||||
import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..))
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Operators
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Store.Direct
|
||||
import Simplex.Chat.Store.Shared
|
||||
|
@ -83,7 +98,7 @@ import Simplex.Chat.Types
|
|||
import Simplex.Chat.Types.Preferences
|
||||
import Simplex.Chat.Types.Shared
|
||||
import Simplex.Chat.Types.UITheme
|
||||
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..))
|
||||
import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..))
|
||||
import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId)
|
||||
import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
|
||||
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
|
||||
|
@ -91,7 +106,7 @@ import qualified Simplex.Messaging.Crypto as C
|
|||
import qualified Simplex.Messaging.Crypto.Ratchet as CR
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON)
|
||||
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode)
|
||||
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode)
|
||||
import Simplex.Messaging.Transport.Client (TransportHost)
|
||||
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8)
|
||||
|
||||
|
@ -515,42 +530,285 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do
|
|||
Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply)
|
||||
_ -> (False, False, Nothing)
|
||||
|
||||
getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> IO [ServerCfg p]
|
||||
getProtocolServers db User {userId} =
|
||||
map toServerCfg
|
||||
getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> IO [UserServer p]
|
||||
getProtocolServers db p User {userId} =
|
||||
map toUserServer
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT host, port, key_hash, basic_auth, preset, tested, enabled
|
||||
SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled
|
||||
FROM protocol_servers
|
||||
WHERE user_id = ? AND protocol = ?;
|
||||
WHERE user_id = ? AND protocol = ?
|
||||
|]
|
||||
(userId, decodeLatin1 $ strEncode protocol)
|
||||
(userId, decodeLatin1 $ strEncode p)
|
||||
where
|
||||
protocol = protocolTypeI @p
|
||||
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p
|
||||
toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) =
|
||||
let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
|
||||
in ServerCfg {server, preset, tested, enabled}
|
||||
toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> UserServer p
|
||||
toUserServer (serverId, host, port, keyHash, auth_, preset, tested, enabled) =
|
||||
let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
|
||||
in UserServer {serverId, server, preset, tested, enabled, deleted = False}
|
||||
|
||||
overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO ()
|
||||
overwriteProtocolServers db User {userId} servers =
|
||||
checkConstraint SEUniqueID . ExceptT $ do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND protocol = ? " (userId, protocol)
|
||||
forM_ servers $ \ServerCfg {server, preset, tested, enabled} -> do
|
||||
let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server
|
||||
insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p)
|
||||
insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled} = do
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO protocol_servers
|
||||
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
(serverColumns p server :. (preset, tested, enabled, userId, ts, ts))
|
||||
sId <- insertedRowId db
|
||||
pure (srv :: NewUserServer p) {serverId = DBEntityId sId}
|
||||
|
||||
updateProtocolServer :: ProtocolTypeI p => DB.Connection -> SProtocolType p -> UTCTime -> UserServer p -> IO ()
|
||||
updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabled} =
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE protocol_servers
|
||||
SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?,
|
||||
preset = ?, tested = ?, enabled = ?, updated_at = ?
|
||||
WHERE smp_server_id = ?
|
||||
|]
|
||||
(serverColumns p server :. (preset, tested, enabled, ts, serverId))
|
||||
|
||||
serverColumns :: ProtocolTypeI p => SProtocolType p -> ProtoServerWithAuth p -> (Text, NonEmpty TransportHost, String, C.KeyHash, Maybe Text)
|
||||
serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) =
|
||||
let protocol = decodeLatin1 $ strEncode p
|
||||
auth = safeDecodeUtf8 . unBasicAuth <$> auth_
|
||||
in (protocol, host, port, keyHash, auth)
|
||||
|
||||
getServerOperators :: DB.Connection -> ExceptT StoreError IO ServerOperatorConditions
|
||||
getServerOperators db = do
|
||||
currentConditions <- getCurrentUsageConditions db
|
||||
liftIO $ do
|
||||
now <- getCurrentTime
|
||||
latestAcceptedConds_ <- getLatestAcceptedConditions db
|
||||
let getConds op = (\ca -> op {conditionsAcceptance = ca}) <$> getOperatorConditions_ db op currentConditions latestAcceptedConds_ now
|
||||
ops <- mapM getConds =<< getServerOperators_ db
|
||||
let conditionsAction = usageConditionsAction ops currentConditions now
|
||||
pure ServerOperatorConditions {serverOperators = ops, currentConditions, conditionsAction}
|
||||
|
||||
getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP])
|
||||
getUserServers db user =
|
||||
(,,)
|
||||
<$> (map Just . serverOperators <$> getServerOperators db)
|
||||
<*> liftIO (getProtocolServers db SPSMP user)
|
||||
<*> liftIO (getProtocolServers db SPXFTP user)
|
||||
|
||||
setServerOperators :: DB.Connection -> NonEmpty ServerOperator -> IO ()
|
||||
setServerOperators db ops = do
|
||||
currentTs <- getCurrentTime
|
||||
mapM_ (updateServerOperator db currentTs) ops
|
||||
|
||||
updateServerOperator :: DB.Connection -> UTCTime -> ServerOperator -> IO ()
|
||||
updateServerOperator db currentTs ServerOperator {operatorId, enabled, smpRoles, xftpRoles} =
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE server_operators
|
||||
SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ?
|
||||
WHERE server_operator_id = ?
|
||||
|]
|
||||
(enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, currentTs, operatorId)
|
||||
|
||||
getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [(Maybe PresetOperator, Maybe ServerOperator)]
|
||||
getUpdateServerOperators db presetOps newUser = do
|
||||
conds <- map toUsageConditions <$> DB.query_ db usageCondsQuery
|
||||
now <- getCurrentTime
|
||||
let (acceptForSimplex_, currentConds, condsToAdd) = usageConditionsToAdd newUser now conds
|
||||
mapM_ insertConditions condsToAdd
|
||||
latestAcceptedConds_ <- getLatestAcceptedConditions db
|
||||
ops <- updatedServerOperators presetOps <$> getServerOperators_ db
|
||||
forM ops $ traverse $ mapM $ \(ASO _ op) -> -- traverse for tuple, mapM for Maybe
|
||||
case operatorId op of
|
||||
DBNewEntity -> do
|
||||
op' <- insertOperator op
|
||||
case (operatorTag op', acceptForSimplex_) of
|
||||
(Just OTSimplex, Just cond) -> autoAcceptConditions op' cond
|
||||
_ -> pure op'
|
||||
DBEntityId _ -> do
|
||||
updateOperator op
|
||||
getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case
|
||||
CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds
|
||||
CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds
|
||||
ca -> pure op {conditionsAcceptance = ca}
|
||||
where
|
||||
insertConditions UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} =
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO protocol_servers
|
||||
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
INSERT INTO usage_conditions
|
||||
(usage_conditions_id, conditions_commit, notified_at, created_at)
|
||||
VALUES (?,?,?,?)
|
||||
|]
|
||||
((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, enabled, userId, currentTs, currentTs))
|
||||
pure $ Right ()
|
||||
(conditionsId, conditionsCommit, notifiedAt, createdAt)
|
||||
updateOperator :: ServerOperator -> IO ()
|
||||
updateOperator ServerOperator {operatorId, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} =
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE server_operators
|
||||
SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?
|
||||
WHERE server_operator_id = ?
|
||||
|]
|
||||
(tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, operatorId)
|
||||
insertOperator :: NewServerOperator -> IO ServerOperator
|
||||
insertOperator op@ServerOperator {operatorTag, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} = do
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO server_operators
|
||||
(server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
(operatorTag, tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles)
|
||||
opId <- insertedRowId db
|
||||
pure op {operatorId = DBEntityId opId}
|
||||
autoAcceptConditions op UsageConditions {conditionsCommit} =
|
||||
acceptConditions_ db op conditionsCommit Nothing
|
||||
$> op {conditionsAcceptance = CAAccepted Nothing}
|
||||
|
||||
serverOperatorQuery :: Query
|
||||
serverOperatorQuery =
|
||||
[sql|
|
||||
SELECT server_operator_id, server_operator_tag, trade_name, legal_name,
|
||||
server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy
|
||||
FROM server_operators
|
||||
|]
|
||||
|
||||
getServerOperators_ :: DB.Connection -> IO [ServerOperator]
|
||||
getServerOperators_ db = map toServerOperator <$> DB.query_ db serverOperatorQuery
|
||||
|
||||
toServerOperator :: (DBEntityId, Maybe OperatorTag, Text, Maybe Text, Text, Bool) :. (Bool, Bool) :. (Bool, Bool) -> ServerOperator
|
||||
toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, enabled) :. smpRoles' :. xftpRoles') =
|
||||
ServerOperator
|
||||
{ operatorId,
|
||||
operatorTag,
|
||||
tradeName,
|
||||
legalName,
|
||||
serverDomains = T.splitOn "," domains,
|
||||
conditionsAcceptance = CARequired Nothing,
|
||||
enabled,
|
||||
smpRoles = serverRoles smpRoles',
|
||||
xftpRoles = serverRoles xftpRoles'
|
||||
}
|
||||
where
|
||||
protocol = decodeLatin1 $ strEncode $ protocolTypeI @p
|
||||
serverRoles (storage, proxy) = ServerRoles {storage, proxy}
|
||||
|
||||
getOperatorConditions_ :: DB.Connection -> ServerOperator -> UsageConditions -> Maybe UsageConditions -> UTCTime -> IO ConditionsAcceptance
|
||||
getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {conditionsCommit = currentCommit, createdAt, notifiedAt} latestAcceptedConds_ now = do
|
||||
case latestAcceptedConds_ of
|
||||
Nothing -> pure $ CARequired Nothing -- no conditions accepted by any operator
|
||||
Just UsageConditions {conditionsCommit = latestAcceptedCommit} -> do
|
||||
operatorAcceptedConds_ <-
|
||||
maybeFirstRow id $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT conditions_commit, accepted_at
|
||||
FROM operator_usage_conditions
|
||||
WHERE server_operator_id = ?
|
||||
ORDER BY operator_usage_conditions_id DESC
|
||||
LIMIT 1
|
||||
|]
|
||||
(Only operatorId)
|
||||
pure $ case operatorAcceptedConds_ of
|
||||
Just (operatorCommit, acceptedAt_)
|
||||
| operatorCommit /= latestAcceptedCommit -> CARequired Nothing -- TODO should we consider this operator disabled?
|
||||
| currentCommit /= latestAcceptedCommit -> CARequired $ conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt)
|
||||
| otherwise -> CAAccepted acceptedAt_
|
||||
_ -> CARequired Nothing -- no conditions were accepted for this operator
|
||||
|
||||
getCurrentUsageConditions :: DB.Connection -> ExceptT StoreError IO UsageConditions
|
||||
getCurrentUsageConditions db =
|
||||
ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $
|
||||
DB.query_ db (usageCondsQuery <> " DESC LIMIT 1")
|
||||
|
||||
usageCondsQuery :: Query
|
||||
usageCondsQuery =
|
||||
[sql|
|
||||
SELECT usage_conditions_id, conditions_commit, notified_at, created_at
|
||||
FROM usage_conditions
|
||||
ORDER BY usage_conditions_id
|
||||
|]
|
||||
|
||||
toUsageConditions :: (Int64, Text, Maybe UTCTime, UTCTime) -> UsageConditions
|
||||
toUsageConditions (conditionsId, conditionsCommit, notifiedAt, createdAt) =
|
||||
UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt}
|
||||
|
||||
getLatestAcceptedConditions :: DB.Connection -> IO (Maybe UsageConditions)
|
||||
getLatestAcceptedConditions db =
|
||||
maybeFirstRow toUsageConditions $
|
||||
DB.query_
|
||||
db
|
||||
[sql|
|
||||
SELECT usage_conditions_id, conditions_commit, notified_at, created_at
|
||||
FROM usage_conditions
|
||||
WHERE conditions_commit = (
|
||||
SELECT conditions_commit
|
||||
FROM operator_usage_conditions
|
||||
ORDER BY accepted_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
|]
|
||||
|
||||
setConditionsNotified :: DB.Connection -> Int64 -> UTCTime -> IO ()
|
||||
setConditionsNotified db condId notifiedAt =
|
||||
DB.execute db "UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ?" (notifiedAt, condId)
|
||||
|
||||
acceptConditions :: DB.Connection -> Int64 -> NonEmpty Int64 -> UTCTime -> ExceptT StoreError IO ()
|
||||
acceptConditions db condId opIds acceptedAt = do
|
||||
UsageConditions {conditionsCommit} <- getUsageConditionsById_ db condId
|
||||
operators <- mapM getServerOperator_ opIds
|
||||
let ts = Just acceptedAt
|
||||
liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit ts
|
||||
where
|
||||
getServerOperator_ opId =
|
||||
ExceptT $ firstRow toServerOperator (SEOperatorNotFound opId) $
|
||||
DB.query db (serverOperatorQuery <> " WHERE server_operator_id = ?") (Only opId)
|
||||
|
||||
acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> Maybe UTCTime -> IO ()
|
||||
acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt =
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO operator_usage_conditions
|
||||
(server_operator_id, server_operator_tag, conditions_commit, accepted_at)
|
||||
VALUES (?,?,?,?)
|
||||
|]
|
||||
(operatorId, operatorTag, conditionsCommit, acceptedAt)
|
||||
|
||||
getUsageConditionsById_ :: DB.Connection -> Int64 -> ExceptT StoreError IO UsageConditions
|
||||
getUsageConditionsById_ db conditionsId =
|
||||
ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT usage_conditions_id, conditions_commit, notified_at, created_at
|
||||
FROM usage_conditions
|
||||
WHERE usage_conditions_id = ?
|
||||
|]
|
||||
(Only conditionsId)
|
||||
|
||||
setUserServers :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> ExceptT StoreError IO UserOperatorServers
|
||||
setUserServers db user ts = checkConstraint SEUniqueID . liftIO . setUserServers' db user ts
|
||||
|
||||
setUserServers' :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> IO UserOperatorServers
|
||||
setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, smpServers, xftpServers} = do
|
||||
mapM_ (updateServerOperator db ts) operator
|
||||
smpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPSMP) smpServers
|
||||
xftpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPXFTP) xftpServers
|
||||
pure UserOperatorServers {operator, smpServers = smpSrvs', xftpServers = xftpSrvs'}
|
||||
where
|
||||
upsertOrDelete :: ProtocolTypeI p => SProtocolType p -> AUserServer p -> IO (Maybe (UserServer p))
|
||||
upsertOrDelete p (AUS _ s@UserServer {serverId, deleted}) = case serverId of
|
||||
DBNewEntity
|
||||
| deleted -> pure Nothing
|
||||
| otherwise -> Just <$> insertProtocolServer db p user ts s
|
||||
DBEntityId srvId
|
||||
| deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, False)
|
||||
| otherwise -> Just s <$ updateProtocolServer db p ts s
|
||||
|
||||
createCall :: DB.Connection -> User -> Call -> UTCTime -> IO ()
|
||||
createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do
|
||||
|
|
|
@ -127,6 +127,8 @@ data StoreError
|
|||
| SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId}
|
||||
| SERemoteCtrlDuplicateCA
|
||||
| SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId}
|
||||
| SEOperatorNotFound {serverOperatorId :: Int64}
|
||||
| SEUsageConditionsNotFound
|
||||
deriving (Show, Exception)
|
||||
|
||||
$(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedLists #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module Simplex.Chat.Terminal where
|
||||
|
@ -13,15 +14,15 @@ import qualified Data.Text as T
|
|||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Database.SQLite.Simple (SQLError (..))
|
||||
import qualified Database.SQLite.Simple as DB
|
||||
import Simplex.Chat (defaultChatConfig)
|
||||
import Simplex.Chat (_defaultNtfServers, defaultChatConfig, operatorSimpleXChat)
|
||||
import Simplex.Chat.Controller
|
||||
import Simplex.Chat.Core
|
||||
import Simplex.Chat.Help (chatWelcome)
|
||||
import Simplex.Chat.Operators
|
||||
import Simplex.Chat.Options
|
||||
import Simplex.Chat.Terminal.Input
|
||||
import Simplex.Chat.Terminal.Output
|
||||
import Simplex.FileTransfer.Client.Presets (defaultXFTPServers)
|
||||
import Simplex.Messaging.Agent.Env.SQLite (presetServerCfg)
|
||||
import Simplex.Messaging.Client (NetworkConfig (..), SMPProxyFallback (..), SMPProxyMode (..), defaultNetworkConfig)
|
||||
import Simplex.Messaging.Util (raceAny_)
|
||||
import System.IO (hFlush, hSetEcho, stdin, stdout)
|
||||
|
@ -29,20 +30,24 @@ import System.IO (hFlush, hSetEcho, stdin, stdout)
|
|||
terminalChatConfig :: ChatConfig
|
||||
terminalChatConfig =
|
||||
defaultChatConfig
|
||||
{ defaultServers =
|
||||
DefaultAgentServers
|
||||
{ smp =
|
||||
L.fromList $
|
||||
map
|
||||
(presetServerCfg True)
|
||||
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion",
|
||||
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion",
|
||||
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion"
|
||||
],
|
||||
useSMP = 3,
|
||||
ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"],
|
||||
xftp = L.map (presetServerCfg True) defaultXFTPServers,
|
||||
useXFTP = L.length defaultXFTPServers,
|
||||
{ presetServers =
|
||||
PresetServers
|
||||
{ operators =
|
||||
[ PresetOperator
|
||||
{ operator = Just operatorSimpleXChat,
|
||||
smp =
|
||||
map
|
||||
(presetServer True)
|
||||
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion",
|
||||
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion",
|
||||
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion"
|
||||
],
|
||||
useSMP = 3,
|
||||
xftp = map (presetServer True) $ L.toList defaultXFTPServers,
|
||||
useXFTP = 3
|
||||
}
|
||||
],
|
||||
ntf = _defaultNtfServers,
|
||||
netCfg =
|
||||
defaultNetworkConfig
|
||||
{ smpProxyMode = SPMUnknown,
|
||||
|
|
|
@ -10,7 +10,7 @@ import Data.Maybe (fromMaybe)
|
|||
import Data.Time.Clock (getCurrentTime)
|
||||
import Data.Time.LocalTime (getCurrentTimeZone)
|
||||
import Network.Socket
|
||||
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), DefaultAgentServers (DefaultAgentServers, netCfg), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString)
|
||||
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString)
|
||||
import Simplex.Chat.Core
|
||||
import Simplex.Chat.Options
|
||||
import Simplex.Chat.Terminal
|
||||
|
@ -56,7 +56,7 @@ simplexChatCLI' cfg opts@ChatOpts {chatCmd, chatCmdLog, chatCmdDelay, chatServer
|
|||
putStrLn $ serializeChatResponse (rh, Just user) ts tz rh r
|
||||
|
||||
welcome :: ChatConfig -> ChatOpts -> IO ()
|
||||
welcome ChatConfig {defaultServers = DefaultAgentServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} =
|
||||
welcome ChatConfig {presetServers = PresetServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} =
|
||||
mapM_
|
||||
putStrLn
|
||||
[ versionString versionNumber,
|
||||
|
|
|
@ -25,6 +25,7 @@ import qualified Data.List.NonEmpty as L
|
|||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)
|
||||
import Data.String
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (decodeLatin1)
|
||||
|
@ -42,6 +43,7 @@ import Simplex.Chat.Help
|
|||
import Simplex.Chat.Markdown
|
||||
import Simplex.Chat.Messages hiding (NewChatItem (..))
|
||||
import Simplex.Chat.Messages.CIContent
|
||||
import Simplex.Chat.Operators
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange)
|
||||
import Simplex.Chat.Remote.Types
|
||||
|
@ -53,7 +55,7 @@ import Simplex.Chat.Types.Shared
|
|||
import Simplex.Chat.Types.UITheme
|
||||
import qualified Simplex.FileTransfer.Transport as XFTP
|
||||
import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..))
|
||||
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerCfg (..))
|
||||
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerRoles (..))
|
||||
import Simplex.Messaging.Agent.Protocol
|
||||
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
|
||||
import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..), SocksMode (..))
|
||||
|
@ -63,7 +65,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR
|
|||
import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON)
|
||||
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..))
|
||||
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol)
|
||||
import qualified Simplex.Messaging.Protocol as SMP
|
||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||
import Simplex.Messaging.Util (safeDecodeUtf8, tshow)
|
||||
|
@ -95,8 +97,11 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
|||
CRChats chats -> viewChats ts tz chats
|
||||
CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat]
|
||||
CRApiParsedMarkdown ft -> [viewJSON ft]
|
||||
CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView
|
||||
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
|
||||
CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca
|
||||
CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp)
|
||||
CRUserServersValidation {} -> []
|
||||
CRUsageConditions current _ accepted_ -> viewUsageConditions current accepted_
|
||||
CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl
|
||||
CRNetworkConfig cfg -> viewNetworkConfig cfg
|
||||
CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile
|
||||
|
@ -1209,27 +1214,43 @@ viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', sho
|
|||
"profile is " <> if isJust viewPwdHash then "hidden" else "visible"
|
||||
]
|
||||
|
||||
viewUserServers :: AUserProtoServers -> Bool -> [StyledString]
|
||||
viewUserServers (AUPS UserProtoServers {serverProtocol = p, protoServers, presetServers}) testView =
|
||||
customServers
|
||||
<> if testView
|
||||
then []
|
||||
else
|
||||
[ "",
|
||||
"use " <> highlight (srvCmd <> " test <srv>") <> " to test " <> pName <> " server connection",
|
||||
"use " <> highlight (srvCmd <> " <srv1[,srv2,...]>") <> " to configure " <> pName <> " servers",
|
||||
"use " <> highlight (srvCmd <> " default") <> " to remove configured " <> pName <> " servers and use presets"
|
||||
]
|
||||
<> case p of
|
||||
SPSMP -> ["(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)"]
|
||||
SPXFTP -> ["(chat option " <> highlight' "-xftp-servers" <> " has precedence over saved XFTP servers for chat session)"]
|
||||
viewUserServers :: UserOperatorServers -> [StyledString]
|
||||
viewUserServers (UserOperatorServers _ [] []) = []
|
||||
viewUserServers UserOperatorServers {operator, smpServers, xftpServers} =
|
||||
[plain $ maybe "Your servers" shortViewOperator operator]
|
||||
<> viewServers SPSMP smpServers
|
||||
<> viewServers SPXFTP xftpServers
|
||||
where
|
||||
srvCmd = "/" <> strEncode p
|
||||
pName = protocolName p
|
||||
customServers =
|
||||
if null protoServers
|
||||
then ("no " <> pName <> " servers saved, using presets: ") : viewServers presetServers
|
||||
else viewServers protoServers
|
||||
viewServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [UserServer p] -> [StyledString]
|
||||
viewServers _ [] = []
|
||||
viewServers p srvs
|
||||
| maybe True (\ServerOperator {enabled} -> enabled) operator =
|
||||
[" " <> protocolName p <> " servers" <> maybe "" ((" " <>) . viewRoles) operator]
|
||||
<> map (plain . (" " <> ) . viewServer) srvs
|
||||
| otherwise = []
|
||||
where
|
||||
viewServer UserServer {server, preset, tested, enabled} = safeDecodeUtf8 (strEncode server) <> serverInfo
|
||||
where
|
||||
serverInfo = if null serverInfo_ then "" else parens $ T.intercalate ", " serverInfo_
|
||||
serverInfo_ = ["preset" | preset] <> testedInfo <> ["disabled" | not enabled]
|
||||
testedInfo = maybe [] (\t -> ["test: " <> if t then "passed" else "failed"]) tested
|
||||
viewRoles op@ServerOperator {enabled}
|
||||
| not enabled = "disabled"
|
||||
| storage rs && proxy rs = "enabled"
|
||||
| storage rs = "enabled storage"
|
||||
| proxy rs = "enabled proxy"
|
||||
| otherwise = "disabled (servers known)"
|
||||
where
|
||||
rs = operatorRoles p op
|
||||
|
||||
serversUserHelp :: [StyledString]
|
||||
serversUserHelp =
|
||||
[ "",
|
||||
"use " <> highlight' "/smp test <srv>" <> " to test SMP server connection",
|
||||
"use " <> highlight' "/smp <srv1[,srv2,...]>" <> " to configure SMP servers",
|
||||
"or the same commands starting from /xftp for XFTP servers",
|
||||
"chat options " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") and " <> highlight' "--xftp-servers" <> " have precedence over preset servers for new user profiles"
|
||||
]
|
||||
|
||||
protocolName :: ProtocolTypeI p => SProtocolType p -> StyledString
|
||||
protocolName = plain . map toUpper . T.unpack . decodeLatin1 . strEncode
|
||||
|
@ -1250,6 +1271,68 @@ viewServerTestResult (AProtoServerWithAuth p _) = \case
|
|||
where
|
||||
pName = protocolName p
|
||||
|
||||
viewServerOperators :: [ServerOperator] -> Maybe UsageConditionsAction -> [StyledString]
|
||||
viewServerOperators ops ca = map (plain . viewOperator) ops <> maybe [] viewConditionsAction ca
|
||||
|
||||
viewOperator :: ServerOperator' s -> Text
|
||||
viewOperator op@ServerOperator {tradeName, legalName, serverDomains, conditionsAcceptance} =
|
||||
viewOpIdTag op
|
||||
<> tradeName
|
||||
<> maybe "" parens legalName
|
||||
<> (", domains: " <> T.intercalate ", " serverDomains)
|
||||
<> (", servers: " <> viewOpEnabled op)
|
||||
<> (", conditions: " <> viewOpConditions conditionsAcceptance)
|
||||
|
||||
shortViewOperator :: ServerOperator -> Text
|
||||
shortViewOperator ServerOperator {operatorId = DBEntityId opId, tradeName, enabled} =
|
||||
tshow opId <> ". " <> tradeName <> parens (if enabled then "enabled" else "disabled")
|
||||
|
||||
viewOpIdTag :: ServerOperator' s -> Text
|
||||
viewOpIdTag ServerOperator {operatorId, operatorTag} = case operatorId of
|
||||
DBEntityId i -> tshow i <> tag
|
||||
DBNewEntity -> tag
|
||||
where
|
||||
tag = maybe "" (parens . textEncode) operatorTag <> ". "
|
||||
|
||||
viewOpConditions :: ConditionsAcceptance -> Text
|
||||
viewOpConditions = \case
|
||||
CAAccepted ts -> viewCond "accepted" ts
|
||||
CARequired ts -> viewCond "required" ts
|
||||
where
|
||||
viewCond w ts = w <> maybe "" (parens . tshow) ts
|
||||
|
||||
viewOpEnabled :: ServerOperator' s -> Text
|
||||
viewOpEnabled ServerOperator {enabled, smpRoles, xftpRoles}
|
||||
| not enabled = "disabled"
|
||||
| no smpRoles && no xftpRoles = "disabled (servers known)"
|
||||
| both smpRoles && both xftpRoles = "enabled"
|
||||
| otherwise = "SMP " <> viewRoles smpRoles <> ", XFTP " <> viewRoles xftpRoles
|
||||
where
|
||||
no rs = not $ storage rs || proxy rs
|
||||
both rs = storage rs && proxy rs
|
||||
viewRoles rs
|
||||
| both rs = "enabled"
|
||||
| storage rs = "enabled storage"
|
||||
| proxy rs = "enabled proxy"
|
||||
| otherwise = "disabled (servers known)"
|
||||
|
||||
viewConditionsAction :: UsageConditionsAction -> [StyledString]
|
||||
viewConditionsAction = \case
|
||||
UCAReview {operators, deadline, showNotice} | showNotice -> case deadline of
|
||||
Just ts -> [plain $ "The new conditions will be accepted for " <> ops <> " at " <> tshow ts]
|
||||
Nothing -> [plain $ "The new conditions have to be accepted for " <> ops]
|
||||
where
|
||||
ops = T.intercalate ", " $ map legalName_ operators
|
||||
legalName_ ServerOperator {tradeName, legalName} = fromMaybe tradeName legalName
|
||||
_ -> []
|
||||
|
||||
viewUsageConditions :: UsageConditions -> Maybe UsageConditions -> [StyledString]
|
||||
viewUsageConditions current accepted_ =
|
||||
[plain $ "Current conditions: " <> viewConds current <> maybe "" (\ac -> ", accepted conditions: " <> viewConds ac) accepted_]
|
||||
where
|
||||
viewConds UsageConditions {conditionsId, conditionsCommit, notifiedAt} =
|
||||
tshow conditionsId <> maybe "" (const " (notified)") notifiedAt <> ". " <> conditionsCommit
|
||||
|
||||
viewChatItemTTL :: Maybe Int64 -> [StyledString]
|
||||
viewChatItemTTL = \case
|
||||
Nothing -> ["old messages are not being deleted"]
|
||||
|
@ -1326,9 +1409,6 @@ viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} =
|
|||
["receiving messages via: " <> viewRcvQueuesInfo rcvQueuesInfo | not $ null rcvQueuesInfo]
|
||||
<> ["sending messages via: " <> viewSndQueuesInfo sndQueuesInfo | not $ null sndQueuesInfo]
|
||||
|
||||
viewServers :: ProtocolTypeI p => NonEmpty (ServerCfg p) -> [StyledString]
|
||||
viewServers = map (plain . B.unpack . strEncode . (\ServerCfg {server} -> server)) . L.toList
|
||||
|
||||
viewRcvQueuesInfo :: [RcvQueueInfo] -> StyledString
|
||||
viewRcvQueuesInfo = plain . intercalate ", " . map showQueueInfo
|
||||
where
|
||||
|
@ -1926,7 +2006,9 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo
|
|||
then [versionString version, updateStr, "simplexmq: " <> simplexmqVersion <> parens simplexmqCommit]
|
||||
else [versionString version, updateStr]
|
||||
where
|
||||
parens s = " (" <> s <> ")"
|
||||
|
||||
parens :: (IsString a, Semigroup a) => a -> a
|
||||
parens s = " (" <> s <> ")"
|
||||
|
||||
viewRemoteHosts :: [RemoteHostInfo] -> [StyledString]
|
||||
viewRemoteHosts = \case
|
||||
|
|
|
@ -376,6 +376,16 @@ userName :: TestCC -> IO [Char]
|
|||
userName (TestCC ChatController {currentUser} _ _ _ _ _) =
|
||||
maybe "no current user" (\User {localDisplayName} -> T.unpack localDisplayName) <$> readTVarIO currentUser
|
||||
|
||||
testChat :: HasCallStack => Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO ()
|
||||
testChat = testChatCfgOpts testCfg testOpts
|
||||
|
||||
testChatCfgOpts :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO ()
|
||||
testChatCfgOpts cfg opts p test = testChatN cfg opts [p] test_
|
||||
where
|
||||
test_ :: HasCallStack => [TestCC] -> IO ()
|
||||
test_ [tc] = test tc
|
||||
test_ _ = error "expected 1 chat client"
|
||||
|
||||
testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO ()
|
||||
testChat2 = testChatCfgOpts2 testCfg testOpts
|
||||
|
||||
|
@ -423,11 +433,10 @@ smpServerCfg =
|
|||
ServerConfig
|
||||
{ transports = [(serverPort, transport @TLS, False)],
|
||||
tbqSize = 1,
|
||||
-- serverTbqSize = 1,
|
||||
msgQueueQuota = 16,
|
||||
msgStoreType = AMSType SMSMemory,
|
||||
maxJournalMsgCount = 1000,
|
||||
maxJournalStateLines = 1000,
|
||||
msgQueueQuota = 16,
|
||||
maxJournalMsgCount = 24,
|
||||
maxJournalStateLines = 4,
|
||||
queueIdBytes = 12,
|
||||
msgIdBytes = 6,
|
||||
storeLogFile = Nothing,
|
||||
|
|
|
@ -25,7 +25,7 @@ import Database.SQLite.Simple (Only (..))
|
|||
import Simplex.Chat.AppSettings (defaultAppSettings)
|
||||
import qualified Simplex.Chat.AppSettings as AS
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Controller (ChatConfig (..), DefaultAgentServers (..))
|
||||
import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..))
|
||||
import Simplex.Chat.Messages (ChatItemId)
|
||||
import Simplex.Chat.Options
|
||||
import Simplex.Chat.Protocol (supportedChatVRange)
|
||||
|
@ -85,6 +85,8 @@ chatDirectTests = do
|
|||
describe "XFTP servers" $ do
|
||||
it "get and set XFTP servers" testGetSetXFTPServers
|
||||
it "test XFTP server connection" testTestXFTPServer
|
||||
describe "operators and usage conditions" $ do
|
||||
it "get and enable operators, accept conditions" testOperators
|
||||
describe "async connection handshake" $ do
|
||||
describe "connect when initiating client goes offline" $ do
|
||||
it "curr" $ testAsyncInitiatingOffline testCfg testCfg
|
||||
|
@ -240,6 +242,7 @@ testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile te
|
|||
bob <##. "smp agent error: BROKER"
|
||||
withSmpServer' serverCfg' $ do
|
||||
alice <## "server connected localhost ()"
|
||||
threadDelay 250000
|
||||
bob ##> ("/_connect plan 1 " <> inv)
|
||||
bob <## "invitation link: ok to connect"
|
||||
bob ##> ("/_connect 1 " <> inv)
|
||||
|
@ -334,8 +337,8 @@ testRetryConnectingClientTimeout tmp = do
|
|||
{ quotaExceededTimeout = 1,
|
||||
messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval}
|
||||
},
|
||||
defaultServers =
|
||||
let def@DefaultAgentServers {netCfg} = defaultServers testCfg
|
||||
presetServers =
|
||||
let def@PresetServers {netCfg} = presetServers testCfg
|
||||
in def {netCfg = (netCfg :: NetworkConfig) {tcpTimeout = 10}}
|
||||
}
|
||||
opts' =
|
||||
|
@ -1139,24 +1142,36 @@ testSendMultiManyBatches =
|
|||
|
||||
testGetSetSMPServers :: HasCallStack => FilePath -> IO ()
|
||||
testGetSetSMPServers =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice _ -> do
|
||||
alice #$> ("/_servers 1 smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001")
|
||||
testChat aliceProfile $
|
||||
\alice -> do
|
||||
alice ##> "/_servers 1"
|
||||
alice <## "Your servers"
|
||||
alice <## " SMP servers"
|
||||
alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"
|
||||
alice <## " XFTP servers"
|
||||
alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"
|
||||
alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok")
|
||||
alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im")
|
||||
alice ##> "/smp"
|
||||
alice <## "Your servers"
|
||||
alice <## " SMP servers"
|
||||
alice <## " smp://1234-w==@smp1.example.im"
|
||||
alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok")
|
||||
alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im")
|
||||
-- alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im")
|
||||
alice ##> "/smp"
|
||||
alice <## "Your servers"
|
||||
alice <## " SMP servers"
|
||||
alice <## " smp://1234-w==:password@smp1.example.im"
|
||||
alice #$> ("/smp smp://2345-w==@smp2.example.im smp://3456-w==@smp3.example.im:5224", id, "ok")
|
||||
alice ##> "/smp"
|
||||
alice <## "smp://2345-w==@smp2.example.im"
|
||||
alice <## "smp://3456-w==@smp3.example.im:5224"
|
||||
alice #$> ("/smp default", id, "ok")
|
||||
alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001")
|
||||
alice <## "Your servers"
|
||||
alice <## " SMP servers"
|
||||
alice <## " smp://2345-w==@smp2.example.im"
|
||||
alice <## " smp://3456-w==@smp3.example.im:5224"
|
||||
|
||||
testTestSMPServerConnection :: HasCallStack => FilePath -> IO ()
|
||||
testTestSMPServerConnection =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice _ -> do
|
||||
testChat aliceProfile $
|
||||
\alice -> do
|
||||
alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"
|
||||
alice <## "SMP server test passed"
|
||||
-- to test with password:
|
||||
|
@ -1170,24 +1185,35 @@ testTestSMPServerConnection =
|
|||
|
||||
testGetSetXFTPServers :: HasCallStack => FilePath -> IO ()
|
||||
testGetSetXFTPServers =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice _ -> withXFTPServer $ do
|
||||
alice #$> ("/_servers 1 xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002")
|
||||
testChat aliceProfile $
|
||||
\alice -> withXFTPServer $ do
|
||||
alice ##> "/_servers 1"
|
||||
alice <## "Your servers"
|
||||
alice <## " SMP servers"
|
||||
alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"
|
||||
alice <## " XFTP servers"
|
||||
alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"
|
||||
alice #$> ("/xftp xftp://1234-w==@xftp1.example.im", id, "ok")
|
||||
alice #$> ("/xftp", id, "xftp://1234-w==@xftp1.example.im")
|
||||
alice ##> "/xftp"
|
||||
alice <## "Your servers"
|
||||
alice <## " XFTP servers"
|
||||
alice <## " xftp://1234-w==@xftp1.example.im"
|
||||
alice #$> ("/xftp xftp://1234-w==:password@xftp1.example.im", id, "ok")
|
||||
alice #$> ("/xftp", id, "xftp://1234-w==:password@xftp1.example.im")
|
||||
alice ##> "/xftp"
|
||||
alice <## "Your servers"
|
||||
alice <## " XFTP servers"
|
||||
alice <## " xftp://1234-w==:password@xftp1.example.im"
|
||||
alice #$> ("/xftp xftp://2345-w==@xftp2.example.im xftp://3456-w==@xftp3.example.im:5224", id, "ok")
|
||||
alice ##> "/xftp"
|
||||
alice <## "xftp://2345-w==@xftp2.example.im"
|
||||
alice <## "xftp://3456-w==@xftp3.example.im:5224"
|
||||
alice #$> ("/xftp default", id, "ok")
|
||||
alice #$> ("/xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002")
|
||||
alice <## "Your servers"
|
||||
alice <## " XFTP servers"
|
||||
alice <## " xftp://2345-w==@xftp2.example.im"
|
||||
alice <## " xftp://3456-w==@xftp3.example.im:5224"
|
||||
|
||||
testTestXFTPServer :: HasCallStack => FilePath -> IO ()
|
||||
testTestXFTPServer =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice _ -> withXFTPServer $ do
|
||||
testChat aliceProfile $
|
||||
\alice -> withXFTPServer $ do
|
||||
alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7002"
|
||||
alice <## "XFTP server test passed"
|
||||
-- to test with password:
|
||||
|
@ -1199,6 +1225,36 @@ testTestXFTPServer =
|
|||
alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK}"
|
||||
alice <## "Possibly, certificate fingerprint in XFTP server address is incorrect"
|
||||
|
||||
testOperators :: HasCallStack => FilePath -> IO ()
|
||||
testOperators =
|
||||
testChatCfgOpts testCfg opts' aliceProfile $
|
||||
\alice -> do
|
||||
-- initial load
|
||||
alice ##> "/_conditions"
|
||||
alice <##. "Current conditions: 2."
|
||||
alice ##> "/_operators"
|
||||
alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required ("
|
||||
alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required"
|
||||
alice <##. "The new conditions will be accepted for SimpleX Chat Ltd at "
|
||||
-- set conditions notified
|
||||
alice ##> "/_conditions_notified 2"
|
||||
alice <## "ok"
|
||||
alice ##> "/_operators"
|
||||
alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required ("
|
||||
alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required"
|
||||
alice ##> "/_conditions"
|
||||
alice <##. "Current conditions: 2 (notified)."
|
||||
-- accept conditions
|
||||
alice ##> "/_accept_conditions 2 1,2"
|
||||
alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: accepted ("
|
||||
alice <##. "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: accepted ("
|
||||
-- update operators
|
||||
alice ##> "/operators 2:on:smp=proxy"
|
||||
alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: accepted ("
|
||||
alice <##. "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: SMP enabled proxy, XFTP enabled, conditions: accepted ("
|
||||
where
|
||||
opts' = testOpts {coreOptions = testCoreOpts {smpServers = [], xftpServers = []}}
|
||||
|
||||
testAsyncInitiatingOffline :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO ()
|
||||
testAsyncInitiatingOffline aliceCfg bobCfg tmp = do
|
||||
inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do
|
||||
|
@ -1800,11 +1856,15 @@ testCreateUserSameServers =
|
|||
where
|
||||
checkCustomServers alice = do
|
||||
alice ##> "/smp"
|
||||
alice <## "smp://2345-w==@smp2.example.im"
|
||||
alice <## "smp://3456-w==@smp3.example.im:5224"
|
||||
alice <## "Your servers"
|
||||
alice <## " SMP servers"
|
||||
alice <## " smp://2345-w==@smp2.example.im"
|
||||
alice <## " smp://3456-w==@smp3.example.im:5224"
|
||||
alice ##> "/xftp"
|
||||
alice <## "xftp://2345-w==@xftp2.example.im"
|
||||
alice <## "xftp://3456-w==@xftp3.example.im:5224"
|
||||
alice <## "Your servers"
|
||||
alice <## " XFTP servers"
|
||||
alice <## " xftp://2345-w==@xftp2.example.im"
|
||||
alice <## " xftp://3456-w==@xftp3.example.im:5224"
|
||||
|
||||
testDeleteUser :: HasCallStack => FilePath -> IO ()
|
||||
testDeleteUser =
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE NumericUnderscores #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE PostfixOperators #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
|
||||
|
||||
module ChatTests.Groups where
|
||||
|
||||
|
@ -1986,7 +1988,6 @@ testGroupAsync tmp = do
|
|||
(bob <## "#team: you joined the group")
|
||||
alice #> "#team hello bob"
|
||||
bob <# "#team alice> hello bob"
|
||||
print (1 :: Integer)
|
||||
withTestChat tmp "alice" $ \alice -> do
|
||||
withNewTestChat tmp "cath" cathProfile $ \cath -> do
|
||||
alice <## "1 contacts connected (use /cs for the list)"
|
||||
|
@ -2006,7 +2007,6 @@ testGroupAsync tmp = do
|
|||
]
|
||||
alice #> "#team hello cath"
|
||||
cath <# "#team alice> hello cath"
|
||||
print (2 :: Integer)
|
||||
withTestChat tmp "bob" $ \bob -> do
|
||||
withTestChat tmp "cath" $ \cath -> do
|
||||
concurrentlyN_
|
||||
|
@ -2022,7 +2022,6 @@ testGroupAsync tmp = do
|
|||
cath <## "#team: member bob (Bob) is connected"
|
||||
]
|
||||
threadDelay 500000
|
||||
print (3 :: Integer)
|
||||
withTestChat tmp "bob" $ \bob -> do
|
||||
withNewTestChat tmp "dan" danProfile $ \dan -> do
|
||||
bob <## "2 contacts connected (use /cs for the list)"
|
||||
|
@ -2042,7 +2041,6 @@ testGroupAsync tmp = do
|
|||
]
|
||||
threadDelay 1000000
|
||||
threadDelay 1000000
|
||||
print (4 :: Integer)
|
||||
withTestChat tmp "alice" $ \alice -> do
|
||||
withTestChat tmp "cath" $ \cath -> do
|
||||
withTestChat tmp "dan" $ \dan -> do
|
||||
|
@ -2064,7 +2062,6 @@ testGroupAsync tmp = do
|
|||
dan <## "#team: member cath (Catherine) is connected"
|
||||
]
|
||||
threadDelay 1000000
|
||||
print (5 :: Integer)
|
||||
withTestChat tmp "alice" $ \alice -> do
|
||||
withTestChat tmp "bob" $ \bob -> do
|
||||
withTestChat tmp "cath" $ \cath -> do
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE PostfixOperators #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
|
||||
|
||||
module ChatTests.Profiles where
|
||||
|
||||
|
@ -272,6 +273,7 @@ testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile
|
|||
bob <##. "smp agent error: BROKER"
|
||||
withSmpServer' serverCfg' $ do
|
||||
alice <## "server connected localhost ()"
|
||||
threadDelay 250000
|
||||
bob ##> ("/_connect plan 1 " <> cLink)
|
||||
bob <## "contact address: ok to connect"
|
||||
bob ##> ("/_connect 1 " <> cLink)
|
||||
|
@ -1733,7 +1735,15 @@ testChangePCCUserDiffSrv tmp = do
|
|||
-- Create new user with different servers
|
||||
alice ##> "/create user alisa"
|
||||
showActiveUser alice "alisa"
|
||||
alice #$> ("/smp smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003", id, "ok")
|
||||
alice ##> "/smp"
|
||||
alice <## "Your servers"
|
||||
alice <## " SMP servers"
|
||||
alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"
|
||||
alice #$> ("/smp smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003", id, "ok")
|
||||
alice ##> "/smp"
|
||||
alice <## "Your servers"
|
||||
alice <## " SMP servers"
|
||||
alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003"
|
||||
alice ##> "/user alice"
|
||||
showActiveUser alice "alice (Alice)"
|
||||
-- Change connection to newly created user and use the newly created connection
|
||||
|
|
132
tests/OperatorTests.hs
Normal file
|
@ -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"]
|
||||
}
|
|
@ -1,51 +1,66 @@
|
|||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE GADTs #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE StandaloneDeriving #-}
|
||||
{-# LANGUAGE TypeSynonymInstances #-}
|
||||
{-# OPTIONS_GHC -Wno-orphans #-}
|
||||
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
|
||||
|
||||
module RandomServers where
|
||||
|
||||
import Control.Monad (replicateM)
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Simplex.Chat (cfgServers, cfgServersToUse, defaultChatConfig, randomServers)
|
||||
import Simplex.Chat.Controller (ChatConfig (..))
|
||||
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..))
|
||||
import Data.Foldable (foldMap')
|
||||
import Data.List (sortOn)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import Data.Monoid (Sum (..))
|
||||
import Simplex.Chat (defaultChatConfig, chooseRandomServers)
|
||||
import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..))
|
||||
import Simplex.Chat.Operators
|
||||
import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..))
|
||||
import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), SProtocolType (..), UserProtocol)
|
||||
import Test.Hspec
|
||||
|
||||
randomServersTests :: Spec
|
||||
randomServersTests = describe "choosig random servers" $ do
|
||||
it "should choose 4 random SMP servers and keep the rest disabled" testRandomSMPServers
|
||||
it "should keep all 6 XFTP servers" testRandomXFTPServers
|
||||
it "should choose 4 + 3 random SMP servers and keep the rest disabled" testRandomSMPServers
|
||||
it "should choose 3 + 3 random XFTP servers and keep the rest disabled" testRandomXFTPServers
|
||||
|
||||
deriving instance Eq (ServerCfg p)
|
||||
deriving instance Eq ServerRoles
|
||||
|
||||
deriving instance Eq (UserServer' s p)
|
||||
|
||||
testRandomSMPServers :: IO ()
|
||||
testRandomSMPServers = do
|
||||
[srvs1, srvs2, srvs3] <-
|
||||
replicateM 3 $
|
||||
checkEnabled SPSMP 4 False =<< randomServers SPSMP defaultChatConfig
|
||||
checkEnabled SPSMP 7 False =<< chooseRandomServers (presetServers defaultChatConfig)
|
||||
(srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` False -- && to avoid rare failures
|
||||
|
||||
testRandomXFTPServers :: IO ()
|
||||
testRandomXFTPServers = do
|
||||
[srvs1, srvs2, srvs3] <-
|
||||
replicateM 3 $
|
||||
checkEnabled SPXFTP 6 True =<< randomServers SPXFTP defaultChatConfig
|
||||
(srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` True
|
||||
checkEnabled SPXFTP 6 False =<< chooseRandomServers (presetServers defaultChatConfig)
|
||||
(srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` False -- && to avoid rare failures
|
||||
|
||||
checkEnabled :: UserProtocol p => SProtocolType p -> Int -> Bool -> (L.NonEmpty (ServerCfg p), [ServerCfg p]) -> IO [ServerCfg p]
|
||||
checkEnabled p n allUsed (srvs, _) = do
|
||||
let def = defaultServers defaultChatConfig
|
||||
cfgSrvs = L.sortWith server' $ cfgServers p def
|
||||
toUse = cfgServersToUse p def
|
||||
srvs == cfgSrvs `shouldBe` allUsed
|
||||
L.map enable srvs `shouldBe` L.map enable cfgSrvs
|
||||
let enbldSrvs = L.filter (\ServerCfg {enabled} -> enabled) srvs
|
||||
checkEnabled :: UserProtocol p => SProtocolType p -> Int -> Bool -> NonEmpty (PresetOperator) -> IO [NewUserServer p]
|
||||
checkEnabled p n allUsed presetOps' = do
|
||||
let PresetServers {operators = presetOps} = presetServers defaultChatConfig
|
||||
presetSrvs = sortOn server' $ concatMap (pServers p) presetOps
|
||||
srvs' = sortOn server' $ concatMap (pServers p) presetOps'
|
||||
Sum toUse = foldMap' (Sum . operatorServersToUse p) presetOps
|
||||
Sum toUse' = foldMap' (Sum . operatorServersToUse p) presetOps'
|
||||
length presetOps `shouldBe` length presetOps'
|
||||
toUse `shouldBe` toUse'
|
||||
srvs' == presetSrvs `shouldBe` allUsed
|
||||
map enable srvs' `shouldBe` map enable presetSrvs
|
||||
let enbldSrvs = filter (\UserServer {enabled} -> enabled) srvs'
|
||||
toUse `shouldBe` n
|
||||
length enbldSrvs `shouldBe` n
|
||||
pure enbldSrvs
|
||||
where
|
||||
server' ServerCfg {server = ProtoServerWithAuth srv _} = srv
|
||||
enable :: forall p. ServerCfg p -> ServerCfg p
|
||||
enable srv = (srv :: ServerCfg p) {enabled = False}
|
||||
server' UserServer {server = ProtoServerWithAuth srv _} = srv
|
||||
enable :: forall p. NewUserServer p -> NewUserServer p
|
||||
enable srv = (srv :: NewUserServer p) {enabled = False}
|
||||
|
|
|
@ -10,6 +10,7 @@ import MarkdownTests
|
|||
import MessageBatching
|
||||
import MobileTests
|
||||
import ProtocolTests
|
||||
import OperatorTests
|
||||
import RandomServers
|
||||
import RemoteTests
|
||||
import SchemaDump
|
||||
|
@ -31,6 +32,7 @@ main = do
|
|||
around tmpBracket $ describe "WebRTC encryption" webRTCTests
|
||||
describe "Valid names" validNameTests
|
||||
describe "Message batching" batchingTests
|
||||
describe "Operators" operatorTests
|
||||
describe "Random servers" randomServersTests
|
||||
around testBracket $ do
|
||||
describe "Mobile API Tests" mobileTests
|
||||
|
|