Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2024-11-23 12:32:45 +00:00
141 changed files with 10231 additions and 2814 deletions
+11 -11
View File
@@ -124,12 +124,12 @@ jobs:
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Unix prepare cabal.project.local for Mac
@@ -138,21 +138,21 @@ jobs:
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Install AppImage dependencies
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
run: sudo apt install -y desktop-file-utils
- name: Install pkg-config for Mac
- name: Install openssl for Mac
if: matrix.os == 'macos-latest' || matrix.os == 'macos-13'
run: brew install pkg-config
run: brew install openssl@3.0
- name: Unix prepare cabal.project.local for Ubuntu
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
@@ -334,7 +334,7 @@ jobs:
run: |
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
scripts/desktop/prepare-openssl-windows.sh
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-3.0.15 | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
rm cabal.project.local 2>/dev/null || true
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "Flux_logo_blue_white.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

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

After

Width:  |  Height:  |  Size: 34 KiB

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

After

Width:  |  Height:  |  Size: 17 KiB

+48 -5
View File
@@ -9,6 +9,18 @@ import SwiftUI
import Intents
import SimpleXChat
private enum NoticesSheet: Identifiable {
case whatsNew(updatedConditions: Bool)
case updatedConditions
var id: String {
switch self {
case .whatsNew: return "whatsNew"
case .updatedConditions: return "updatedConditions"
}
}
}
struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared
@@ -30,7 +42,8 @@ struct ContentView: View {
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
@State private var showWhatsNew = false
@State private var noticesShown = false
@State private var noticesSheetItem: NoticesSheet? = nil
@State private var showChooseLAMode = false
@State private var showSetPasscode = false
@State private var waitingForOrPassedAuth = true
@@ -261,8 +274,15 @@ struct ContentView: View {
alertManager.showAlert(laNoticeAlert())
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if !showWhatsNew {
showWhatsNew = shouldShowWhatsNew()
if !noticesShown {
let showWhatsNew = shouldShowWhatsNew()
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
noticesShown = showWhatsNew || showUpdatedConditions
if showWhatsNew {
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
} else if showUpdatedConditions {
noticesSheetItem = .updatedConditions
}
}
}
}
@@ -270,8 +290,22 @@ struct ContentView: View {
connectViaUrl()
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.sheet(isPresented: $showWhatsNew) {
WhatsNewView()
.sheet(item: $noticesSheetItem) { item in
switch item {
case let .whatsNew(updatedConditions):
WhatsNewView(updatedConditions: updatedConditions)
.modifier(ThemedBackground())
.if(updatedConditions) { v in
v.task { await setConditionsNotified_() }
}
case .updatedConditions:
UsageConditionsView(
currUserServers: Binding.constant([]),
userServers: Binding.constant([])
)
.modifier(ThemedBackground(grouped: true))
.task { await setConditionsNotified_() }
}
}
if chatModel.setDeliveryReceipts {
SetDeliveryReceiptsView()
@@ -283,6 +317,15 @@ struct ContentView: View {
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity)
}
private func setConditionsNotified_() async {
do {
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
try await setConditionsNotified(conditionsId: conditionsId)
} catch let error {
logger.error("setConditionsNotified error: \(responseError(error))")
}
}
private func processUserActivity(_ activity: NSUserActivity) {
let intent = activity.interaction?.intent
if let intent = intent as? INStartCallIntent {
+2
View File
@@ -193,6 +193,8 @@ final class ChatModel: ObservableObject {
@Published var draft: ComposeState?
@Published var draftChatId: String?
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
// usage conditions
@Published var conditions: ServerOperatorConditions = .empty
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
+70 -20
View File
@@ -500,18 +500,6 @@ func apiDeleteToken(token: DeviceToken) async throws {
try await sendCommandOkResp(.apiDeleteToken(token: token))
}
func getUserProtoServers(_ serverProtocol: ServerProtocol) throws -> UserProtoServers {
let userId = try currentUserId("getUserProtoServers")
let r = chatSendCmdSync(.apiGetUserProtoServers(userId: userId, serverProtocol: serverProtocol))
if case let .userProtoServers(_, servers) = r { return servers }
throw r
}
func setUserProtoServers(_ serverProtocol: ServerProtocol, servers: [ServerCfg]) async throws {
let userId = try currentUserId("setUserProtoServers")
try await sendCommandOkResp(.apiSetUserProtoServers(userId: userId, serverProtocol: serverProtocol, servers: servers))
}
func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> {
let userId = try currentUserId("testProtoServer")
let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
@@ -524,6 +512,65 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail
throw r
}
func getServerOperators() throws -> ServerOperatorConditions {
let r = chatSendCmdSync(.apiGetServerOperators)
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("getServerOperators error: \(String(describing: r))")
throw r
}
func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions {
let r = await chatSendCmd(.apiSetServerOperators(operators: operators))
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("setServerOperators error: \(String(describing: r))")
throw r
}
func getUserServers() async throws -> [UserOperatorServers] {
let userId = try currentUserId("getUserServers")
let r = await chatSendCmd(.apiGetUserServers(userId: userId))
if case let .userServers(_, userServers) = r { return userServers }
logger.error("getUserServers error: \(String(describing: r))")
throw r
}
func setUserServers(userServers: [UserOperatorServers]) async throws {
let userId = try currentUserId("setUserServers")
let r = await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers))
if case .cmdOk = r { return }
logger.error("setUserServers error: \(String(describing: r))")
throw r
}
func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] {
let userId = try currentUserId("validateServers")
let r = await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers))
if case let .userServersValidation(_, serverErrors) = r { return serverErrors }
logger.error("validateServers error: \(String(describing: r))")
throw r
}
func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) {
let r = await chatSendCmd(.apiGetUsageConditions)
if case let .usageConditions(usageConditions, conditionsText, acceptedConditions) = r { return (usageConditions, conditionsText, acceptedConditions) }
logger.error("getUsageConditions error: \(String(describing: r))")
throw r
}
func setConditionsNotified(conditionsId: Int64) async throws {
let r = await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId))
if case .cmdOk = r { return }
logger.error("setConditionsNotified error: \(String(describing: r))")
throw r
}
func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions {
let r = await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds))
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("acceptConditions error: \(String(describing: r))")
throw r
}
func getChatItemTTL() throws -> ChatItemTTL {
let userId = try currentUserId("getChatItemTTL")
return try chatItemTTLResponse(chatSendCmdSync(.apiGetChatItemTTL(userId: userId)))
@@ -1558,6 +1605,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
m.chatInitialized = true
m.currentUser = try apiGetActiveUser()
m.conditions = try getServerOperators()
if m.currentUser == nil {
onboardingStageDefault.set(.step1_SimpleXInfo)
privacyDeliveryReceiptsSet.set(true)
@@ -1602,7 +1650,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws {
}
}
func startChat(refreshInvitations: Bool = true) throws {
func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws {
logger.debug("startChat")
let m = ChatModel.shared
try setNetworkConfig(getNetCfg())
@@ -1621,13 +1669,15 @@ func startChat(refreshInvitations: Bool = true) throws {
if let token = m.deviceToken {
registerToken(token: token)
}
withAnimation {
let savedOnboardingStage = onboardingStageDefault.get()
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
? .step3_CreateSimpleXAddress
: savedOnboardingStage
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
m.setDeliveryReceipts = true
if !onboarding {
withAnimation {
let savedOnboardingStage = onboardingStageDefault.get()
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
? .step3_ChooseServerOperators
: savedOnboardingStage
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
m.setDeliveryReceipts = true
}
}
}
}
@@ -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 {
@@ -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()
}
@@ -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) {
+74 -50
View File
@@ -144,20 +144,22 @@
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */; };
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; };
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; };
6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; };
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; };
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642BA82C2CE50495005E9412 /* NewServerView.swift */; };
642BA8332CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */; };
642BA8342CEB3D4B005E9412 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82F2CEB3D4B005E9412 /* libffi.a */; };
642BA8352CEB3D4B005E9412 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8302CEB3D4B005E9412 /* libgmp.a */; };
642BA8362CEB3D4B005E9412 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8312CEB3D4B005E9412 /* libgmpxx.a */; };
642BA8372CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */; };
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B402CCBEB080083A2CF /* libgmpxx.a */; };
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */; };
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B422CCBEB080083A2CF /* libffi.a */; };
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.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 */; };
6449333A2AF8E51000AC506E /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 644933352AF8E51000AC506E /* libgmpxx.a */; };
@@ -205,7 +207,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 */; };
@@ -441,7 +445,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>"; };
@@ -492,20 +496,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.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-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.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.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>"; };
644933352AF8E51000AC506E /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
@@ -554,7 +560,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>"; };
@@ -667,14 +675,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.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a in Frameworks */,
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-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;
};
@@ -751,6 +759,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>";
@@ -822,11 +835,6 @@
5CC2C0FA2809BF11000C35E3 /* Localizable.strings */,
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */,
5C764E5C279C70B7000C6508 /* Libraries */,
643B3B422CCBEB080083A2CF /* libffi.a */,
643B3B442CCBEB080083A2CF /* libgmp.a */,
643B3B402CCBEB080083A2CF /* libgmpxx.a */,
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */,
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */,
5CA059C2279559F40002BEB4 /* Shared */,
5CDCAD462818589900503DA2 /* SimpleX NSE */,
CEE723A82C3BD3D70009AE93 /* SimpleX SE */,
@@ -885,13 +893,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>";
@@ -922,10 +932,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 */,
@@ -933,9 +942,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 */,
@@ -1066,6 +1072,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 = (
@@ -1385,10 +1405,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 */,
@@ -1415,12 +1437,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 */,
@@ -1538,7 +1560,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;
};
@@ -1913,7 +1937,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 245;
CURRENT_PROJECT_VERSION = 246;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -1938,7 +1962,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES_THIN;
MARKETING_VERSION = 6.1.1;
MARKETING_VERSION = 6.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1962,7 +1986,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 245;
CURRENT_PROJECT_VERSION = 246;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -1987,7 +2011,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.1.1;
MARKETING_VERSION = 6.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -2003,11 +2027,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 245;
CURRENT_PROJECT_VERSION = 246;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.1.1;
MARKETING_VERSION = 6.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2023,11 +2047,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 245;
CURRENT_PROJECT_VERSION = 246;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.1.1;
MARKETING_VERSION = 6.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2048,7 +2072,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 245;
CURRENT_PROJECT_VERSION = 246;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2063,7 +2087,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.1.1;
MARKETING_VERSION = 6.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2085,7 +2109,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 245;
CURRENT_PROJECT_VERSION = 246;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2100,7 +2124,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.1.1;
MARKETING_VERSION = 6.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2122,7 +2146,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 245;
CURRENT_PROJECT_VERSION = 246;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2148,7 +2172,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.1.1;
MARKETING_VERSION = 6.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2173,7 +2197,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 245;
CURRENT_PROJECT_VERSION = 246;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2199,7 +2223,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.1.1;
MARKETING_VERSION = 6.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2224,7 +2248,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 245;
CURRENT_PROJECT_VERSION = 246;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2239,7 +2263,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.1.1;
MARKETING_VERSION = 6.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2258,7 +2282,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 245;
CURRENT_PROJECT_VERSION = 246;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2273,7 +2297,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.1.1;
MARKETING_VERSION = 6.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
+406 -41
View File
@@ -72,9 +72,15 @@ public enum ChatCommand {
case apiGetGroupLink(groupId: Int64)
case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64)
case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent)
case apiGetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol)
case apiSetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol, servers: [ServerCfg])
case apiTestProtoServer(userId: Int64, server: String)
case apiGetServerOperators
case apiSetServerOperators(operators: [ServerOperator])
case apiGetUserServers(userId: Int64)
case apiSetUserServers(userId: Int64, userServers: [UserOperatorServers])
case apiValidateServers(userId: Int64, userServers: [UserOperatorServers])
case apiGetUsageConditions
case apiSetConditionsNotified(conditionsId: Int64)
case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64])
case apiSetChatItemTTL(userId: Int64, seconds: Int64?)
case apiGetChatItemTTL(userId: Int64)
case apiSetNetworkConfig(networkConfig: NetCfg)
@@ -231,9 +237,15 @@ public enum ChatCommand {
case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)"
case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)"
case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)"
case let .apiGetUserProtoServers(userId, serverProtocol): return "/_servers \(userId) \(serverProtocol)"
case let .apiSetUserProtoServers(userId, serverProtocol, servers): return "/_servers \(userId) \(serverProtocol) \(protoServersStr(servers))"
case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)"
case .apiGetServerOperators: return "/_operators"
case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))"
case let .apiGetUserServers(userId): return "/_servers \(userId)"
case let .apiSetUserServers(userId, userServers): return "/_servers \(userId) \(encodeJSON(userServers))"
case let .apiValidateServers(userId, userServers): return "/_validate_servers \(userId) \(encodeJSON(userServers))"
case .apiGetUsageConditions: return "/_conditions"
case let .apiSetConditionsNotified(conditionsId): return "/_conditions_notified \(conditionsId)"
case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))"
case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
@@ -386,9 +398,15 @@ public enum ChatCommand {
case .apiGetGroupLink: return "apiGetGroupLink"
case .apiCreateMemberContact: return "apiCreateMemberContact"
case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation"
case .apiGetUserProtoServers: return "apiGetUserProtoServers"
case .apiSetUserProtoServers: return "apiSetUserProtoServers"
case .apiTestProtoServer: return "apiTestProtoServer"
case .apiGetServerOperators: return "apiGetServerOperators"
case .apiSetServerOperators: return "apiSetServerOperators"
case .apiGetUserServers: return "apiGetUserServers"
case .apiSetUserServers: return "apiSetUserServers"
case .apiValidateServers: return "apiValidateServers"
case .apiGetUsageConditions: return "apiGetUsageConditions"
case .apiSetConditionsNotified: return "apiSetConditionsNotified"
case .apiAcceptConditions: return "apiAcceptConditions"
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
@@ -475,10 +493,6 @@ public enum ChatCommand {
func joinedIds(_ ids: [Int64]) -> String {
ids.map { "\($0)" }.joined(separator: ",")
}
func protoServersStr(_ servers: [ServerCfg]) -> String {
encodeJSON(ProtoServersConfig(servers: servers))
}
func chatItemTTLStr(seconds: Int64?) -> String {
if let seconds = seconds {
@@ -548,8 +562,11 @@ public enum ChatResponse: Decodable, Error {
case apiChats(user: UserRef, chats: [ChatData])
case apiChat(user: UserRef, chat: ChatData)
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
case userProtoServers(user: UserRef, servers: UserProtoServers)
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
case serverOperatorConditions(conditions: ServerOperatorConditions)
case userServers(user: UserRef, userServers: [UserOperatorServers])
case userServersValidation(user: UserRef, serverErrors: [UserServersError])
case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?)
case chatItemTTL(user: UserRef, chatItemTTL: Int64?)
case networkConfig(networkConfig: NetCfg)
case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?)
@@ -721,8 +738,11 @@ public enum ChatResponse: Decodable, Error {
case .apiChats: return "apiChats"
case .apiChat: return "apiChat"
case .chatItemInfo: return "chatItemInfo"
case .userProtoServers: return "userProtoServers"
case .serverTestResult: return "serverTestResult"
case .serverOperatorConditions: return "serverOperators"
case .userServers: return "userServers"
case .userServersValidation: return "userServersValidation"
case .usageConditions: return "usageConditions"
case .chatItemTTL: return "chatItemTTL"
case .networkConfig: return "networkConfig"
case .contactInfo: return "contactInfo"
@@ -890,8 +910,11 @@ public enum ChatResponse: Decodable, Error {
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))")
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))"
case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))")
case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))")
case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))"
case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL))
case let .networkConfig(networkConfig): return String(describing: networkConfig)
case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))")
@@ -1175,86 +1198,428 @@ public struct DBEncryptionConfig: Codable {
public var newKey: String
}
struct SMPServersConfig: Encodable {
var smpServers: [ServerCfg]
}
public enum ServerProtocol: String, Decodable {
case smp
case xftp
}
public struct ProtoServersConfig: Codable {
public var servers: [ServerCfg]
public enum OperatorTag: String, Codable {
case simplex = "simplex"
case flux = "flux"
case xyz = "xyz"
case demo = "demo"
}
public struct UserProtoServers: Decodable {
public var serverProtocol: ServerProtocol
public var protoServers: [ServerCfg]
public var presetServers: [ServerCfg]
public struct ServerOperatorInfo: Decodable {
public var description: [String]
public var website: String
public var logo: String
public var largeLogo: String
public var logoDarkMode: String
public var largeLogoDarkMode: String
}
public struct ServerCfg: Identifiable, Equatable, Codable, Hashable {
public let operatorsInfo: Dictionary<OperatorTag, ServerOperatorInfo> = [
.simplex: ServerOperatorInfo(
description: [
"SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.",
"SimpleX Chat Ltd develops the communication software for SimpleX network."
],
website: "https://simplex.chat",
logo: "decentralized",
largeLogo: "logo",
logoDarkMode: "decentralized-light",
largeLogoDarkMode: "logo-light"
),
.flux: ServerOperatorInfo(
description: [
"Flux is the largest decentralized cloud infrastructure, leveraging a global network of user-operated computational nodes.",
"Flux offers a powerful, scalable, and affordable platform designed to support individuals, businesses, and cutting-edge technologies like AI. With high uptime and worldwide distribution, Flux ensures reliable, accessible cloud computing for all."
],
website: "https://runonflux.com",
logo: "flux_logo_symbol",
largeLogo: "flux_logo",
logoDarkMode: "flux_logo_symbol",
largeLogoDarkMode: "flux_logo-light"
),
.xyz: ServerOperatorInfo(
description: ["XYZ servers"],
website: "XYZ website",
logo: "shield",
largeLogo: "logo",
logoDarkMode: "shield",
largeLogoDarkMode: "logo-light"
),
.demo: ServerOperatorInfo(
description: ["Demo operator"],
website: "Demo website",
logo: "decentralized",
largeLogo: "logo",
logoDarkMode: "decentralized-light",
largeLogoDarkMode: "logo-light"
)
]
public struct UsageConditions: Decodable {
public var conditionsId: Int64
public var conditionsCommit: String
public var notifiedAt: Date?
public var createdAt: Date
public static var sampleData = UsageConditions(
conditionsId: 1,
conditionsCommit: "11a44dc1fd461a93079f897048b46998db55da5c",
notifiedAt: nil,
createdAt: Date.now
)
}
public enum UsageConditionsAction: Decodable {
case review(operators: [ServerOperator], deadline: Date?, showNotice: Bool)
case accepted(operators: [ServerOperator])
public var showNotice: Bool {
switch self {
case let .review(_, _, showNotice): showNotice
case .accepted: false
}
}
}
public struct ServerOperatorConditions: Decodable {
public var serverOperators: [ServerOperator]
public var currentConditions: UsageConditions
public var conditionsAction: UsageConditionsAction?
public static var empty = ServerOperatorConditions(
serverOperators: [],
currentConditions: UsageConditions(conditionsId: 0, conditionsCommit: "empty", notifiedAt: nil, createdAt: .now),
conditionsAction: nil
)
}
public enum ConditionsAcceptance: Equatable, Codable, Hashable {
case accepted(acceptedAt: Date?)
// If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator.
// No deadline indicates it's required to accept conditions for the operator to start using it.
case required(deadline: Date?)
public var conditionsAccepted: Bool {
switch self {
case .accepted: true
case .required: false
}
}
public var usageAllowed: Bool {
switch self {
case .accepted: true
case let .required(deadline): deadline != nil
}
}
}
public struct ServerOperator: Identifiable, Equatable, Codable {
public var operatorId: Int64
public var operatorTag: OperatorTag?
public var tradeName: String
public var legalName: String?
public var serverDomains: [String]
public var conditionsAcceptance: ConditionsAcceptance
public var enabled: Bool
public var smpRoles: ServerRoles
public var xftpRoles: ServerRoles
public var id: Int64 { operatorId }
public static func == (l: ServerOperator, r: ServerOperator) -> Bool {
l.operatorId == r.operatorId && l.operatorTag == r.operatorTag && l.tradeName == r.tradeName && l.legalName == r.legalName &&
l.serverDomains == r.serverDomains && l.conditionsAcceptance == r.conditionsAcceptance && l.enabled == r.enabled &&
l.smpRoles == r.smpRoles && l.xftpRoles == r.xftpRoles
}
public var legalName_: String {
legalName ?? tradeName
}
public var info: ServerOperatorInfo {
return if let operatorTag = operatorTag {
operatorsInfo[operatorTag] ?? ServerOperator.dummyOperatorInfo
} else {
ServerOperator.dummyOperatorInfo
}
}
public static let dummyOperatorInfo = ServerOperatorInfo(
description: ["Default"],
website: "Default",
logo: "decentralized",
largeLogo: "logo",
logoDarkMode: "decentralized-light",
largeLogoDarkMode: "logo-light"
)
public func logo(_ colorScheme: ColorScheme) -> String {
colorScheme == .light ? info.logo : info.logoDarkMode
}
public func largeLogo(_ colorScheme: ColorScheme) -> String {
colorScheme == .light ? info.largeLogo : info.largeLogoDarkMode
}
public static var sampleData1 = ServerOperator(
operatorId: 1,
operatorTag: .simplex,
tradeName: "SimpleX Chat",
legalName: "SimpleX Chat Ltd",
serverDomains: ["simplex.im"],
conditionsAcceptance: .accepted(acceptedAt: nil),
enabled: true,
smpRoles: ServerRoles(storage: true, proxy: true),
xftpRoles: ServerRoles(storage: true, proxy: true)
)
public static var sampleData2 = ServerOperator(
operatorId: 2,
operatorTag: .xyz,
tradeName: "XYZ",
legalName: nil,
serverDomains: ["xyz.com"],
conditionsAcceptance: .required(deadline: nil),
enabled: false,
smpRoles: ServerRoles(storage: false, proxy: true),
xftpRoles: ServerRoles(storage: false, proxy: true)
)
public static var sampleData3 = ServerOperator(
operatorId: 3,
operatorTag: .demo,
tradeName: "Demo",
legalName: nil,
serverDomains: ["demo.com"],
conditionsAcceptance: .required(deadline: nil),
enabled: false,
smpRoles: ServerRoles(storage: true, proxy: false),
xftpRoles: ServerRoles(storage: true, proxy: false)
)
}
public struct ServerRoles: Equatable, Codable {
public var storage: Bool
public var proxy: Bool
}
public struct UserOperatorServers: Identifiable, Equatable, Codable {
public var `operator`: ServerOperator?
public var smpServers: [UserServer]
public var xftpServers: [UserServer]
public var id: String {
if let op = self.operator {
"\(op.operatorId)"
} else {
"nil operator"
}
}
public var operator_: ServerOperator {
get {
self.operator ?? ServerOperator(
operatorId: 0,
operatorTag: nil,
tradeName: "",
legalName: "",
serverDomains: [],
conditionsAcceptance: .accepted(acceptedAt: nil),
enabled: false,
smpRoles: ServerRoles(storage: true, proxy: true),
xftpRoles: ServerRoles(storage: true, proxy: true)
)
}
set { `operator` = newValue }
}
public static var sampleData1 = UserOperatorServers(
operator: ServerOperator.sampleData1,
smpServers: [UserServer.sampleData.preset],
xftpServers: [UserServer.sampleData.xftpPreset]
)
public static var sampleDataNilOperator = UserOperatorServers(
operator: nil,
smpServers: [UserServer.sampleData.preset],
xftpServers: [UserServer.sampleData.xftpPreset]
)
}
public enum UserServersError: Decodable {
case noServers(protocol: ServerProtocol, user: UserRef?)
case storageMissing(protocol: ServerProtocol, user: UserRef?)
case proxyMissing(protocol: ServerProtocol, user: UserRef?)
case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String)
public var globalError: String? {
switch self {
case let .noServers(`protocol`, _):
switch `protocol` {
case .smp: return globalSMPError
case .xftp: return globalXFTPError
}
case let .storageMissing(`protocol`, _):
switch `protocol` {
case .smp: return globalSMPError
case .xftp: return globalXFTPError
}
case let .proxyMissing(`protocol`, _):
switch `protocol` {
case .smp: return globalSMPError
case .xftp: return globalXFTPError
}
default: return nil
}
}
public var globalSMPError: String? {
switch self {
case let .noServers(.smp, user):
let text = NSLocalizedString("No message servers.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
case let .storageMissing(.smp, user):
let text = NSLocalizedString("No servers to receive messages.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
case let .proxyMissing(.smp, user):
let text = NSLocalizedString("No servers for private message routing.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
default:
return nil
}
}
public var globalXFTPError: String? {
switch self {
case let .noServers(.xftp, user):
let text = NSLocalizedString("No media & file servers.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
case let .storageMissing(.xftp, user):
let text = NSLocalizedString("No servers to send files.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
case let .proxyMissing(.xftp, user):
let text = NSLocalizedString("No servers to receive files.", comment: "servers error")
if let user = user {
return userStr(user) + " " + text
} else {
return text
}
default:
return nil
}
}
private func userStr(_ user: UserRef) -> String {
String.localizedStringWithFormat(NSLocalizedString("For chat profile %@:", comment: "servers error"), user.localDisplayName)
}
}
public struct UserServer: Identifiable, Equatable, Codable, Hashable {
public var serverId: Int64?
public var server: String
public var preset: Bool
public var tested: Bool?
public var enabled: Bool
public var deleted: Bool
var createdAt = Date()
// public var sendEnabled: Bool // can we potentially want to prevent sending on the servers we use to receive?
// Even if we don't see the use case, it's probably better to allow it in the model
// In any case, "trusted/known" servers are out of scope of this change
public init(server: String, preset: Bool, tested: Bool?, enabled: Bool) {
public init(serverId: Int64?, server: String, preset: Bool, tested: Bool?, enabled: Bool, deleted: Bool) {
self.serverId = serverId
self.server = server
self.preset = preset
self.tested = tested
self.enabled = enabled
self.deleted = deleted
}
public static func == (l: ServerCfg, r: ServerCfg) -> Bool {
l.server == r.server && l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled
public static func == (l: UserServer, r: UserServer) -> Bool {
l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested &&
l.enabled == r.enabled && l.deleted == r.deleted
}
public var id: String { "\(server) \(createdAt)" }
public static var empty = ServerCfg(server: "", preset: false, tested: nil, enabled: false)
public static var empty = UserServer(serverId: nil, server: "", preset: false, tested: nil, enabled: false, deleted: false)
public var isEmpty: Bool {
server.trimmingCharacters(in: .whitespaces) == ""
}
public struct SampleData {
public var preset: ServerCfg
public var custom: ServerCfg
public var untested: ServerCfg
public var preset: UserServer
public var custom: UserServer
public var untested: UserServer
public var xftpPreset: UserServer
}
public static var sampleData = SampleData(
preset: ServerCfg(
preset: UserServer(
serverId: 1,
server: "smp://abcd@smp8.simplex.im",
preset: true,
tested: true,
enabled: true
enabled: true,
deleted: false
),
custom: ServerCfg(
custom: UserServer(
serverId: 2,
server: "smp://abcd@smp9.simplex.im",
preset: false,
tested: false,
enabled: false
enabled: false,
deleted: false
),
untested: ServerCfg(
untested: UserServer(
serverId: 3,
server: "smp://abcd@smp10.simplex.im",
preset: false,
tested: nil,
enabled: true
enabled: true,
deleted: false
),
xftpPreset: UserServer(
serverId: 4,
server: "xftp://abcd@xftp8.simplex.im",
preset: true,
tested: true,
enabled: true,
deleted: false
)
)
enum CodingKeys: CodingKey {
case serverId
case server
case preset
case tested
case enabled
case deleted
}
}
@@ -1357,7 +1722,7 @@ public struct NetCfg: Codable, Equatable {
public var hostMode: HostMode = .publicHost
public var requiredHostMode = true
public var sessionMode = TransportSessionMode.user
public var smpProxyMode: SMPProxyMode = .unknown
public var smpProxyMode: SMPProxyMode = .always
public var smpProxyFallback: SMPProxyFallback = .allowProtected
public var smpWebPort = false
public var tcpConnectTimeout: Int // microseconds
@@ -15,6 +15,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import chat.simplex.common.AppScreen
import chat.simplex.common.model.clear
import chat.simplex.common.model.clearAndNotify
import chat.simplex.common.views.helpers.*
import androidx.compose.ui.platform.LocalContext as LocalContext1
import chat.simplex.res.MR
@@ -75,7 +76,7 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler {
} else if (chatModel.chatId.value != null) {
// Since no modals are open, the problem is probably in ChatView
chatModel.chatId.value = null
chatModel.chatItems.clear()
chatModel.chatItems.clearAndNotify()
} else {
// ChatList, nothing to do. Maybe to show other view except ChatList
}
@@ -44,3 +44,10 @@ actual fun LocalWindowWidth(): Dp {
(rect.width() / density).dp
}
}
@Composable
actual fun LocalWindowHeight(): Dp {
val view = LocalView.current
val density = LocalDensity.current
return with(density) { view.height.toDp() }
}
@@ -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,7 +192,15 @@ fun MainScreen() {
OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
OnboardingStage.LinkAMobile -> LinkAMobile()
OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null)
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)
}
}
@@ -8,10 +8,11 @@ import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.common.model.ChatModel.chatItemsChangesListener
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrationToDeviceState
import chat.simplex.common.views.migration.MigrationToState
@@ -22,6 +23,7 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.collections.removeAll as remAll
import kotlinx.datetime.*
import kotlinx.datetime.TimeZone
import kotlinx.serialization.*
@@ -35,6 +37,7 @@ import java.net.URI
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.*
import kotlin.collections.ArrayList
import kotlin.random.Random
import kotlin.time.*
@@ -64,7 +67,14 @@ object ChatModel {
// current chat
val chatId = mutableStateOf<String?>(null)
/** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on.
* If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages].
* If you use api call to get the items, use just [add] instead of [addAndNotify].
* Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */
val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
// set listener here that will be notified on every add/delete of a chat item
var chatItemsChangesListener: ChatItemsChangesListener? = null
val chatState = ActiveChatState()
// rhId, chatId
val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
@@ -135,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
@@ -216,6 +228,15 @@ object ChatModel {
popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = 0)
}
private suspend fun reorderChat(chat: Chat, toIndex: Int) {
val newChats = SnapshotStateList<Chat>()
newChats.addAll(chats.value)
newChats.remove(chat)
newChats.add(index = toIndex, chat)
chats.replaceAll(newChats)
popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = toIndex)
}
fun updateChatInfo(rhId: Long?, cInfo: ChatInfo) {
val i = getChatIndex(rhId, cInfo.id)
if (i >= 0) {
@@ -317,7 +338,7 @@ object ChatModel {
chat.chatStats
)
if (appPlatform.isDesktop && cItem.chatDir.sent) {
addChat(chats.removeAt(i))
reorderChat(chats[i], 0)
} else {
popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i)
}
@@ -330,9 +351,9 @@ object ChatModel {
// Prevent situation when chat item already in the list received from backend
if (chatItems.value.none { it.id == cItem.id }) {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
} else {
chatItems.add(cItem)
chatItems.addAndNotify(cItem)
}
}
}
@@ -377,7 +398,7 @@ object ChatModel {
} else {
cItem
}
chatItems.add(ci)
chatItems.addAndNotify(ci)
true
}
} else {
@@ -416,7 +437,7 @@ object ChatModel {
}
// remove from current chat
if (chatId.value == cInfo.id) {
chatItems.removeAll {
chatItems.removeAllAndNotify {
// We delete taking into account meta.createdAt to make sure we will not be in situation when two items with the same id will be deleted
// (it can happen if already deleted chat item in backend still in the list and new one came with the same (re-used) chat item id)
val remove = it.id == cItem.id && it.meta.createdAt == cItem.meta.createdAt
@@ -436,7 +457,7 @@ object ChatModel {
// clear current chat
if (chatId.value == cInfo.id) {
chatItemStatuses.clear()
chatItems.clear()
chatItems.clearAndNotify()
}
}
@@ -607,14 +628,14 @@ object ChatModel {
suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem {
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
withContext(Dispatchers.Main) {
chatItems.add(cItem)
chatItems.addAndNotify(cItem)
}
return cItem
}
fun removeLiveDummy() {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.removeLast()
chatItems.removeLastAndNotify()
}
}
@@ -622,11 +643,17 @@ object ChatModel {
val cInfo = chatInfo
var markedRead = 0
if (chatId.value == cInfo.id) {
var i = 0
val items = chatItems.value
while (i < items.size) {
var i = items.lastIndex
val itemIdsFromRange = if (range != null) {
(range.from .. range.to).toMutableSet()
} else {
mutableSetOf()
}
val markedReadIds = mutableSetOf<Long>()
while (i >= 0) {
val item = items[i]
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || itemIdsFromRange.contains(item.id))) {
val newItem = item.withStatus(CIStatus.RcvRead())
items[i] = newItem
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
@@ -634,10 +661,17 @@ object ChatModel {
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
)
}
markedReadIds.add(item.id)
markedRead++
if (range != null) {
itemIdsFromRange.remove(item.id)
// already set all needed items as read, can finish the loop
if (itemIdsFromRange.isEmpty()) break
}
}
i += 1
i--
}
chatItemsChangesListener?.read(if (range != null) markedReadIds else null, items)
}
return markedRead
}
@@ -684,17 +718,6 @@ object ChatModel {
return count to ns
}
// returns the index of the passed item and the next item (it has smaller index)
fun getNextChatItem(ci: ChatItem): Pair<Int?, ChatItem?> {
val i = getChatItemIndexOrNull(ci)
return if (i != null) {
val reversedChatItems = chatItems.asReversed()
i to if (i > 0) reversedChatItems[i - 1] else null
} else {
null to null
}
}
// returns the index of the first item in the same merged group (the first hidden item)
// and the previous visible item with another merge category
fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair<Int?, ChatItem?> {
@@ -738,7 +761,7 @@ object ChatModel {
fun replaceConnReqView(id: String, withId: String) {
if (id == showingInvitation.value?.connId) {
showingInvitation.value = null
chatModel.chatItems.clear()
chatModel.chatItems.clearAndNotify()
chatModel.chatId.value = withId
ModalManager.start.closeModals()
ModalManager.end.closeModals()
@@ -748,7 +771,7 @@ object ChatModel {
fun dismissConnReqView(id: String) {
if (id == showingInvitation.value?.connId) {
showingInvitation.value = null
chatModel.chatItems.clear()
chatModel.chatItems.clearAndNotify()
chatModel.chatId.value = null
// Close NewChatView
ModalManager.start.closeModals()
@@ -798,6 +821,15 @@ object ChatModel {
fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
}
interface ChatItemsChangesListener {
// pass null itemIds if the whole chat now read
fun read(itemIds: Set<Long>?, newItems: List<ChatItem>)
fun added(item: Pair<Long, Boolean>, index: Int)
// itemId, index in old chatModel.chatItems (before the update), isRcvNew (is item unread or not)
fun removed(itemIds: List<Triple<Long, Int, Boolean>>, newItems: List<ChatItem>)
fun cleared()
}
data class ShowingInvitation(
val connId: String,
val connReq: String,
@@ -1293,6 +1325,12 @@ data class Contact(
}
}
@Serializable
data class NavigationInfo(
val afterUnread: Int = 0,
val afterTotal: Int = 0
)
@Serializable
enum class ContactStatus {
@SerialName("active") Active,
@@ -2279,12 +2317,24 @@ data class ChatItem (
}
}
fun <T> MutableState<SnapshotStateList<T>>.add(index: Int, elem: T) {
value = SnapshotStateList<T>().apply { addAll(value); add(index, elem) }
fun MutableState<SnapshotStateList<Chat>>.add(index: Int, elem: Chat) {
value = SnapshotStateList<Chat>().apply { addAll(value); add(index, elem) }
}
fun <T> MutableState<SnapshotStateList<T>>.add(elem: T) {
value = SnapshotStateList<T>().apply { addAll(value); add(elem) }
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(index: Int, elem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(index, elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, index) }
}
fun MutableState<SnapshotStateList<Chat>>.add(elem: Chat) {
value = SnapshotStateList<Chat>().apply { addAll(value); add(elem) }
}
// For some reason, Kotlin version crashes if the list is empty
fun <T> MutableList<T>.removeAll(predicate: (T) -> Boolean): Boolean = if (isEmpty()) false else remAll(predicate)
// Adds item to chatItems and notifies a listener about newly added item
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(elem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, lastIndex) }
}
fun <T> MutableState<SnapshotStateList<T>>.addAll(index: Int, elems: List<T>) {
@@ -2295,28 +2345,59 @@ fun <T> MutableState<SnapshotStateList<T>>.addAll(elems: List<T>) {
value = SnapshotStateList<T>().apply { addAll(value); addAll(elems) }
}
fun <T> MutableState<SnapshotStateList<T>>.removeAll(block: (T) -> Boolean) {
value = SnapshotStateList<T>().apply { addAll(value); removeAll(block) }
fun MutableState<SnapshotStateList<Chat>>.removeAll(block: (Chat) -> Boolean) {
value = SnapshotStateList<Chat>().apply { addAll(value); removeAll(block) }
}
fun <T> MutableState<SnapshotStateList<T>>.removeAt(index: Int): T {
val new = SnapshotStateList<T>()
// Removes item(s) from chatItems and notifies a listener about removed item(s)
fun MutableState<SnapshotStateList<ChatItem>>.removeAllAndNotify(block: (ChatItem) -> Boolean) {
val toRemove = ArrayList<Triple<Long, Int, Boolean>>()
value = SnapshotStateList<ChatItem>().apply {
addAll(value)
var i = 0
removeAll {
val remove = block(it)
if (remove) toRemove.add(Triple(it.id, i, it.isRcvNew))
i++
remove
}
}
if (toRemove.isNotEmpty()) {
chatItemsChangesListener?.removed(toRemove, value)
}
}
fun MutableState<SnapshotStateList<Chat>>.removeAt(index: Int): Chat {
val new = SnapshotStateList<Chat>()
new.addAll(value)
val res = new.removeAt(index)
value = new
return res
}
fun <T> MutableState<SnapshotStateList<T>>.removeLast() {
value = SnapshotStateList<T>().apply { addAll(value); removeLast() }
fun MutableState<SnapshotStateList<ChatItem>>.removeLastAndNotify() {
val removed: Triple<Long, Int, Boolean>
value = SnapshotStateList<ChatItem>().apply {
addAll(value)
val remIndex = lastIndex
val rem = removeLast()
removed = Triple(rem.id, remIndex, rem.isRcvNew)
}
chatItemsChangesListener?.removed(listOf(removed), value)
}
fun <T> MutableState<SnapshotStateList<T>>.replaceAll(elems: List<T>) {
value = SnapshotStateList<T>().apply { addAll(elems) }
}
fun <T> MutableState<SnapshotStateList<T>>.clear() {
value = SnapshotStateList<T>()
fun MutableState<SnapshotStateList<Chat>>.clear() {
value = SnapshotStateList()
}
// Removes all chatItems and notifies a listener about it
fun MutableState<SnapshotStateList<ChatItem>>.clearAndNotify() {
value = SnapshotStateList()
chatItemsChangesListener?.cleared()
}
fun <T> State<SnapshotStateList<T>>.asReversed(): MutableList<T> = value.asReversed()
@@ -2488,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()
@@ -22,13 +22,16 @@ import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
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
@@ -179,6 +182,7 @@ class AppPreferences {
val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false)
val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true)
val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false)
val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false)
val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true)
val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
val appUpdateChannel = mkEnumPreference(SHARED_PREFS_APP_UPDATE_CHANNEL, AppUpdatesChannel.DISABLED) { AppUpdatesChannel.entries.firstOrNull { it.name == this } }
@@ -254,6 +258,7 @@ class AppPreferences {
val hintPreferences: List<Pair<SharedPreference<Boolean>, Boolean>> = listOf(
laNoticeShown to false,
oneHandUICardShown to false,
addressCreationCardShown to false,
liveMessageAlertShown to false,
showHiddenProfilesNotice to true,
showMuteProfileAlert to true,
@@ -408,6 +413,7 @@ class AppPreferences {
private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown"
private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice"
private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown"
private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown"
private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert"
private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase"
@@ -865,11 +871,15 @@ object ChatController {
return emptyList()
}
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? {
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination, search: String = ""): Pair<Chat, NavigationInfo>? {
val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search))
if (r is CR.ApiChat) return if (rh == null) r.chat else r.chat.copy(remoteHostId = rh)
if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers))
if (pagination is ChatPagination.Around && r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) {
showQuotedItemDoesNotExistAlert()
} else {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers))
}
return null
}
@@ -955,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))
@@ -997,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))
@@ -2858,7 +2938,7 @@ object ChatController {
chatModel.users.addAll(users)
chatModel.currentUser.value = user
if (user == null) {
chatModel.chatItems.clear()
chatModel.chatItems.clearAndNotify()
withChats {
chats.clear()
popChatCollector.clear()
@@ -3029,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()
@@ -3189,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)}"
@@ -3334,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"
@@ -3420,7 +3518,7 @@ sealed class CC {
is GetAgentServersSummary -> "getAgentServersSummary"
}
class ItemRange(val from: Long, val to: Long)
data class ItemRange(val from: Long, val to: Long)
fun chatItemTTLStr(seconds: Long?): String {
if (seconds == null) return "none"
@@ -3451,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))
}
}
@@ -3468,15 +3564,19 @@ sealed class ChatPagination {
class Last(val count: Int): ChatPagination()
class After(val chatItemId: Long, val count: Int): ChatPagination()
class Before(val chatItemId: Long, val count: Int): ChatPagination()
class Around(val chatItemId: Long, val count: Int): ChatPagination()
class Initial(val count: Int): ChatPagination()
val cmdString: String get() = when (this) {
is Last -> "count=${this.count}"
is After -> "after=${this.chatItemId} count=${this.count}"
is Before -> "before=${this.chatItemId} count=${this.count}"
is Around -> "around=${this.chatItemId} count=${this.count}"
is Initial -> "initial=${this.count}"
}
companion object {
const val INITIAL_COUNT = 100
val INITIAL_COUNT = if (appPlatform.isDesktop) 100 else 75
const val PRELOAD_COUNT = 100
const val UNTIL_PRELOAD_COUNT = 50
}
@@ -3498,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()
@@ -3529,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
)
)
}
@@ -3671,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
@@ -4914,10 +5356,13 @@ sealed class CR {
@Serializable @SerialName("chatRunning") class ChatRunning: CR()
@Serializable @SerialName("chatStopped") class ChatStopped: 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): 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()
@@ -5096,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"
@@ -5264,10 +5712,13 @@ sealed class CR {
is ChatRunning -> noDetails()
is ChatStopped -> noDetails()
is ApiChats -> withUser(user, json.encodeToString(chats))
is ApiChat -> withUser(user, json.encodeToString(chat))
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
@@ -138,7 +139,11 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
} else if (startChat().await()) {
val savedOnboardingStage = appPreferences.onboardingStage.get()
val newStage = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
OnboardingStage.Step3_CreateSimpleXAddress
if (appPlatform.isAndroid) {
OnboardingStage.Step4_SetNotificationsMode
} else {
OnboardingStage.OnboardingComplete
}
} else {
savedOnboardingStage
}
@@ -65,7 +65,7 @@ abstract class NtfManager {
}
val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true
if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo, chatModel)
if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo)
}
}
@@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
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.layout.layoutId
@@ -25,6 +26,7 @@ import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.item.CONSOLE_COMPOSE_LAYOUT_ID
import chat.simplex.common.views.chat.item.AdaptingBottomPaddingLayout
import chat.simplex.common.views.chatlist.NavigationBarBackground
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@@ -125,11 +127,11 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
derivedStateOf { chatModel.terminalItems.value.asReversed() }
}
val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState()
var autoScrollToBottom = rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) {
var autoScrollToBottom = listState.firstVisibleItemIndex <= 1
launch {
snapshotFlow { listState.layoutInfo.totalItemsCount }
.filter { autoScrollToBottom }
.filter { autoScrollToBottom.value }
.collect {
try {
listState.scrollToItem(0)
@@ -138,10 +140,16 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
}
}
}
var oldNumberOfElements = listState.layoutInfo.totalItemsCount
launch {
snapshotFlow { listState.firstVisibleItemIndex }
.drop(1)
.collect {
autoScrollToBottom = it == 0
if (oldNumberOfElements != listState.layoutInfo.totalItemsCount) {
oldNumberOfElements = listState.layoutInfo.totalItemsCount
return@collect
}
autoScrollToBottom.value = it == 0
}
}
}
@@ -165,7 +165,7 @@ fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) {
if (!chatModel.connectedToRemote()) {
chatModel.localUserCreated.value = true
}
controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators)
controller.startChat(user)
controller.switchUIRemoteHost(null)
close()
@@ -181,7 +181,7 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: ()
chatModel.currentUser.value = user
if (chatModel.users.isEmpty()) {
chatModel.controller.startChat(user)
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode)
} else {
val users = chatModel.controller.listUsers(rhId)
chatModel.users.clear()
@@ -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.Step3_CreateSimpleXAddress
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,
@@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.ui.theme.*
@@ -201,7 +202,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
SectionItemView(
click = {
withBGApi {
openChat(chatRh, forwardedFromItem.chatInfo, chatModel)
openChat(chatRh, forwardedFromItem.chatInfo)
ModalManager.end.closeModals()
}
},
@@ -477,28 +478,33 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses)
}
}
TabRow(
selectedTabIndex = availableTabs.indexOfFirst { it::class == selection.value::class },
backgroundColor = Color.Transparent,
contentColor = MaterialTheme.colors.primary,
) {
availableTabs.forEach { ciInfoTab ->
Tab(
selected = selection.value::class == ciInfoTab::class,
onClick = {
selection.value = ciInfoTab
},
text = { Text(tabTitle(ciInfoTab), fontSize = 13.sp) },
icon = {
Icon(
painterResource(tabIcon(ciInfoTab)),
tabTitle(ciInfoTab)
)
},
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = MaterialTheme.colors.secondary,
)
val oneHandUI = remember { appPrefs.oneHandUI.state }
Box(Modifier.offset(x = 0.dp, y = if (oneHandUI.value) -AppBarHeight * fontSizeSqrtMultiplier else 0.dp)) {
TabRow(
selectedTabIndex = availableTabs.indexOfFirst { it::class == selection.value::class },
Modifier.height(AppBarHeight * fontSizeSqrtMultiplier),
backgroundColor = MaterialTheme.colors.background,
contentColor = MaterialTheme.colors.primary,
) {
availableTabs.forEach { ciInfoTab ->
LeadingIconTab(
selected = selection.value::class == ciInfoTab::class,
onClick = {
selection.value = ciInfoTab
},
text = { Text(tabTitle(ciInfoTab), fontSize = 13.sp) },
icon = {
Icon(
painterResource(tabIcon(ciInfoTab)),
tabTitle(ciInfoTab)
)
},
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = MaterialTheme.colors.secondary,
)
}
}
Divider()
}
}
} else {
@@ -0,0 +1,301 @@
package chat.simplex.common.views.chat
import androidx.compose.runtime.snapshots.SnapshotStateList
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.chatModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.StateFlow
import kotlin.math.min
const val TRIM_KEEP_COUNT = 200
suspend fun apiLoadMessages(
rhId: Long?,
chatType: ChatType,
apiId: Long,
pagination: ChatPagination,
chatState: ActiveChatState,
search: String = "",
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
) = coroutineScope {
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, pagination, search) ?: return@coroutineScope
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last)
|| !isActive) return@coroutineScope
val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState
val oldItems = chatModel.chatItems.value
val newItems = SnapshotStateList<ChatItem>()
when (pagination) {
is ChatPagination.Initial -> {
val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList()
withChats {
if (chatModel.getChat(chat.id) == null) {
addChat(chat)
}
}
withContext(Dispatchers.Main) {
chatModel.chatItemStatuses.clear()
chatModel.chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id
splits.value = newSplits
if (chat.chatItems.isNotEmpty()) {
unreadAfterItemId.value = chat.chatItems.last().id
}
totalAfter.value = navInfo.afterTotal
unreadTotal.value = chat.chatStats.unreadCount
unreadAfter.value = navInfo.afterUnread
unreadAfterNewestLoaded.value = navInfo.afterUnread
}
}
is ChatPagination.Before -> {
newItems.addAll(oldItems)
val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId }
if (indexInCurrentItems == -1) return@coroutineScope
val (newIds, _) = mapItemsToIds(chat.chatItems)
val wasSize = newItems.size
val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination(
unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed
)
val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0)
newItems.addAll(insertAt, chat.chatItems)
withContext(Dispatchers.Main) {
chatModel.chatItems.replaceAll(newItems)
splits.value = newSplits
chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems)
}
}
is ChatPagination.After -> {
newItems.addAll(oldItems)
val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId }
if (indexInCurrentItems == -1) return@coroutineScope
val mappedItems = mapItemsToIds(chat.chatItems)
val newIds = mappedItems.first
val (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination(
mappedItems.second, pagination.chatItemId, newItems, newIds, chat, splits
)
val indexToAdd = min(indexInCurrentItems + 1, newItems.size)
val indexToAddIsLast = indexToAdd == newItems.size
newItems.addAll(indexToAdd, chat.chatItems)
withContext(Dispatchers.Main) {
chatModel.chatItems.replaceAll(newItems)
splits.value = newSplits
chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems)
// loading clear bottom area, updating number of unread items after the newest loaded item
if (indexToAddIsLast) {
unreadAfterNewestLoaded.value -= unreadInLoaded
}
}
}
is ChatPagination.Around -> {
newItems.addAll(oldItems)
val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)
// currently, items will always be added on top, which is index 0
newItems.addAll(0, chat.chatItems)
withContext(Dispatchers.Main) {
chatModel.chatItems.replaceAll(newItems)
splits.value = listOf(chat.chatItems.last().id) + newSplits
unreadAfterItemId.value = chat.chatItems.last().id
totalAfter.value = navInfo.afterTotal
unreadTotal.value = chat.chatStats.unreadCount
unreadAfter.value = navInfo.afterUnread
// no need to set it, count will be wrong
// unreadAfterNewestLoaded.value = navInfo.afterUnread
}
}
is ChatPagination.Last -> {
newItems.addAll(oldItems)
removeDuplicates(newItems, chat)
newItems.addAll(chat.chatItems)
withContext(Dispatchers.Main) {
chatModel.chatItems.replaceAll(newItems)
unreadAfterNewestLoaded.value = 0
}
}
}
}
private data class ModifiedSplits (
val oldUnreadSplitIndex: Int,
val newUnreadSplitIndex: Int,
val trimmedIds: Set<Long>,
val newSplits: List<Long>,
)
private fun removeDuplicatesAndModifySplitsOnBeforePagination(
unreadAfterItemId: StateFlow<Long>,
newItems: SnapshotStateList<ChatItem>,
newIds: Set<Long>,
splits: StateFlow<List<Long>>,
visibleItemIndexesNonReversed: () -> IntRange
): ModifiedSplits {
var oldUnreadSplitIndex: Int = -1
var newUnreadSplitIndex: Int = -1
val visibleItemIndexes = visibleItemIndexesNonReversed()
var lastSplitIndexTrimmed = -1
var allowedTrimming = true
var index = 0
/** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */
val trimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT .. newItems.size - TRIM_KEEP_COUNT
val trimmedIds = mutableSetOf<Long>()
val prevItemTrimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT + 1 .. newItems.size - TRIM_KEEP_COUNT
var newSplits = splits.value
newItems.removeAll {
val invisibleItemToTrim = trimRange.contains(index) && allowedTrimming
val prevItemWasTrimmed = prevItemTrimRange.contains(index) && allowedTrimming
// may disable it after clearing the whole split range
if (splits.value.isNotEmpty() && it.id == splits.value.firstOrNull()) {
// trim only in one split range
allowedTrimming = false
}
val indexInSplits = splits.value.indexOf(it.id)
if (indexInSplits != -1) {
lastSplitIndexTrimmed = indexInSplits
}
if (invisibleItemToTrim) {
if (prevItemWasTrimmed) {
trimmedIds.add(it.id)
} else {
newUnreadSplitIndex = index
// prev item is not supposed to be trimmed, so exclude current one from trimming and set a split here instead.
// this allows to define splitRange of the oldest items and to start loading trimmed items when user scrolls in the opposite direction
if (lastSplitIndexTrimmed == -1) {
newSplits = listOf(it.id) + newSplits
} else {
val new = ArrayList(newSplits)
new[lastSplitIndexTrimmed] = it.id
newSplits = new
}
}
}
if (unreadAfterItemId.value == it.id) {
oldUnreadSplitIndex = index
}
index++
(invisibleItemToTrim && prevItemWasTrimmed) || newIds.contains(it.id)
}
// will remove any splits that now becomes obsolete because items were merged
newSplits = newSplits.filterNot { split -> newIds.contains(split) || trimmedIds.contains(split) }
return ModifiedSplits(oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits)
}
private fun removeDuplicatesAndModifySplitsOnAfterPagination(
unreadInLoaded: Int,
paginationChatItemId: Long,
newItems: SnapshotStateList<ChatItem>,
newIds: Set<Long>,
chat: Chat,
splits: StateFlow<List<Long>>
): Pair<List<Long>, Int> {
var unreadInLoaded = unreadInLoaded
var firstItemIdBelowAllSplits: Long? = null
val splitsToRemove = ArrayList<Long>()
val indexInSplitRanges = splits.value.indexOf(paginationChatItemId)
// Currently, it should always load from split range
val loadingFromSplitRange = indexInSplitRanges != -1
val splitsToMerge = if (loadingFromSplitRange && indexInSplitRanges + 1 <= splits.value.size) ArrayList(splits.value.subList(indexInSplitRanges + 1, splits.value.size)) else ArrayList()
newItems.removeAll {
val duplicate = newIds.contains(it.id)
if (loadingFromSplitRange && duplicate) {
if (splitsToMerge.contains(it.id)) {
splitsToMerge.remove(it.id)
splitsToRemove.add(it.id)
} else if (firstItemIdBelowAllSplits == null && splitsToMerge.isEmpty()) {
// we passed all splits and found duplicated item below all of them, which means no splits anymore below the loaded items
firstItemIdBelowAllSplits = it.id
}
}
if (duplicate && it.isRcvNew) {
unreadInLoaded--
}
duplicate
}
var newSplits: List<Long> = emptyList()
if (firstItemIdBelowAllSplits != null) {
// no splits anymore, all were merged with bottom items
newSplits = emptyList()
} else {
if (splitsToRemove.isNotEmpty()) {
val new = ArrayList(splits.value)
new.removeAll(splitsToRemove.toSet())
newSplits = new
}
val enlargedSplit = splits.value.indexOf(paginationChatItemId)
if (enlargedSplit != -1) {
// move the split to the end of loaded items
val new = ArrayList(splits.value)
new[enlargedSplit] = chat.chatItems.last().id
newSplits = new
// Log.d(TAG, "Enlarged split range $newSplits")
}
}
return newSplits to unreadInLoaded
}
private fun removeDuplicatesAndUpperSplits(
newItems: SnapshotStateList<ChatItem>,
chat: Chat,
splits: StateFlow<List<Long>>,
visibleItemIndexesNonReversed: () -> IntRange
): List<Long> {
if (splits.value.isEmpty()) {
removeDuplicates(newItems, chat)
return splits.value
}
val newSplits = splits.value.toMutableList()
val visibleItemIndexes = visibleItemIndexesNonReversed()
val (newIds, _) = mapItemsToIds(chat.chatItems)
val idsToTrim = ArrayList<MutableSet<Long>>()
idsToTrim.add(mutableSetOf())
var index = 0
newItems.removeAll {
val duplicate = newIds.contains(it.id)
if (!duplicate && visibleItemIndexes.first > index) {
idsToTrim.last().add(it.id)
}
if (visibleItemIndexes.first > index && splits.value.contains(it.id)) {
newSplits -= it.id
// closing previous range. All items in idsToTrim that ends with empty set should be deleted.
// Otherwise, the last set should be excluded from trimming because it is in currently visible split range
idsToTrim.add(mutableSetOf())
}
index++
duplicate
}
if (idsToTrim.last().isNotEmpty()) {
// it has some elements to trim from currently visible range which means the items shouldn't be trimmed
// Otherwise, the last set would be empty
idsToTrim.removeLast()
}
val allItemsToDelete = idsToTrim.flatten()
if (allItemsToDelete.isNotEmpty()) {
newItems.removeAll { allItemsToDelete.contains(it.id) }
}
return newSplits
}
// ids, number of unread items
private fun mapItemsToIds(items: List<ChatItem>): Pair<Set<Long>, Int> {
var unreadInLoaded = 0
val ids = mutableSetOf<Long>()
var i = 0
while (i < items.size) {
val item = items[i]
ids.add(item.id)
if (item.isRcvNew) {
unreadInLoaded++
}
i++
}
return ids to unreadInLoaded
}
private fun removeDuplicates(newItems: SnapshotStateList<ChatItem>, chat: Chat) {
val (newIds, _) = mapItemsToIds(chat.chatItems)
newItems.removeAll { newIds.contains(it.id) }
}
@@ -0,0 +1,379 @@
package chat.simplex.common.views.chat
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
data class MergedItems (
val items: List<MergedItem>,
val splits: List<SplitRange>,
// chat item id, index in list
val indexInParentItems: Map<Long, Int>,
) {
companion object {
fun create(items: List<ChatItem>, unreadCount: State<Int>, revealedItems: Set<Long>, chatState: ActiveChatState): MergedItems {
if (items.isEmpty()) return MergedItems(emptyList(), emptyList(), emptyMap())
val unreadAfterItemId = chatState.unreadAfterItemId
val itemSplits = chatState.splits.value
val mergedItems = ArrayList<MergedItem>()
// Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems
val splitRanges = ArrayList<SplitRange>()
val indexInParentItems = mutableMapOf<Long, Int>()
var index = 0
var unclosedSplitIndex: Int? = null
var unclosedSplitIndexInParent: Int? = null
var visibleItemIndexInParent = -1
var unreadBefore = unreadCount.value - chatState.unreadAfterNewestLoaded.value
var lastRevealedIdsInMergedItems: MutableList<Long>? = null
var lastRangeInReversedForMergedItems: MutableStateFlow<IntRange>? = null
var recent: MergedItem? = null
while (index < items.size) {
val item = items[index]
val prev = items.getOrNull(index - 1)
val next = items.getOrNull(index + 1)
val category = item.mergeCategory
val itemIsSplit = itemSplits.contains(item.id)
if (item.id == unreadAfterItemId.value) {
unreadBefore = unreadCount.value - chatState.unreadAfter.value
}
if (item.isRcvNew) unreadBefore--
val revealed = item.mergeCategory == null || revealedItems.contains(item.id)
if (recent is MergedItem.Grouped && recent.mergeCategory == category && !revealedItems.contains(recent.items.first().item.id) && !itemIsSplit) {
val listItem = ListItem(item, prev, next, unreadBefore)
recent.items.add(listItem)
if (item.isRcvNew) {
recent.unreadIds.add(item.id)
}
if (lastRevealedIdsInMergedItems != null && lastRangeInReversedForMergedItems != null) {
if (revealed) {
lastRevealedIdsInMergedItems += item.id
}
lastRangeInReversedForMergedItems.value = lastRangeInReversedForMergedItems.value.first..index
}
} else {
visibleItemIndexInParent++
val listItem = ListItem(item, prev, next, unreadBefore)
recent = if (item.mergeCategory != null) {
if (item.mergeCategory != prev?.mergeCategory || lastRevealedIdsInMergedItems == null) {
lastRevealedIdsInMergedItems = if (revealedItems.contains(item.id)) mutableListOf(item.id) else mutableListOf()
} else if (revealed) {
lastRevealedIdsInMergedItems += item.id
}
lastRangeInReversedForMergedItems = MutableStateFlow(index .. index)
MergedItem.Grouped(
items = arrayListOf(listItem),
revealed = revealed,
revealedIdsWithinGroup = lastRevealedIdsInMergedItems,
rangeInReversed = lastRangeInReversedForMergedItems,
mergeCategory = item.mergeCategory,
startIndexInReversedItems = index,
unreadIds = if (item.isRcvNew) mutableSetOf(item.id) else mutableSetOf()
)
} else {
lastRangeInReversedForMergedItems = null
MergedItem.Single(
item = listItem,
startIndexInReversedItems = index
)
}
mergedItems.add(recent)
}
if (itemIsSplit) {
// found item that is considered as a split
if (unclosedSplitIndex != null && unclosedSplitIndexInParent != null) {
// it was at least second split in the list
splitRanges.add(SplitRange(unclosedSplitIndex until index, unclosedSplitIndexInParent until visibleItemIndexInParent))
}
unclosedSplitIndex = index
unclosedSplitIndexInParent = visibleItemIndexInParent
} else if (index + 1 == items.size && unclosedSplitIndex != null && unclosedSplitIndexInParent != null) {
// just one split for the whole list, there will be no more, it's the end
splitRanges.add(SplitRange(unclosedSplitIndex .. index, unclosedSplitIndexInParent .. visibleItemIndexInParent))
}
indexInParentItems[item.id] = visibleItemIndexInParent
index++
}
return MergedItems(
mergedItems,
splitRanges,
indexInParentItems
)
}
}
}
sealed class MergedItem {
abstract val startIndexInReversedItems: Int
// the item that is always single, cannot be grouped and always revealed
data class Single(
val item: ListItem,
override val startIndexInReversedItems: Int,
): MergedItem()
/** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed,
* there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance
* of [Grouped] item with all grouped items inside [items]. In other words, number of [MergedItem] will always be equal to number of
* visible rows in ChatView LazyColumn */
@Stable
data class Grouped (
val items: ArrayList<ListItem>,
val revealed: Boolean,
// it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action
// it's the same list instance for all Grouped items within revealed group
/** @see reveal */
val revealedIdsWithinGroup: MutableList<Long>,
val rangeInReversed: MutableStateFlow<IntRange>,
val mergeCategory: CIMergeCategory?,
val unreadIds: MutableSet<Long>,
override val startIndexInReversedItems: Int,
): MergedItem() {
fun reveal(reveal: Boolean, revealedItems: MutableState<Set<Long>>) {
val newRevealed = revealedItems.value.toMutableSet()
var i = 0
if (reveal) {
while (i < items.size) {
newRevealed.add(items[i].item.id)
i++
}
} else {
while (i < revealedIdsWithinGroup.size) {
newRevealed.remove(revealedIdsWithinGroup[i])
i++
}
revealedIdsWithinGroup.clear()
}
revealedItems.value = newRevealed
}
}
fun hasUnread(): Boolean = when (this) {
is Single -> item.item.isRcvNew
is Grouped -> unreadIds.isNotEmpty()
}
fun newest(): ListItem = when (this) {
is Single -> item
is Grouped -> items.first()
}
fun oldest(): ListItem = when (this) {
is Single -> item
is Grouped -> items.last()
}
fun lastIndexInReversed(): Int = when (this) {
is Single -> startIndexInReversedItems
is Grouped -> startIndexInReversedItems + items.lastIndex
}
}
data class SplitRange(
/** range of indexes inside reversedChatItems where the first element is the split (it's index is [indexRangeInReversed.first])
* so [0, 1, 2, -100-, 101] if the 3 is a split, SplitRange(indexRange = 3 .. 4) will be this SplitRange instance
* (3, 4 indexes of the splitRange with the split itself at index 3)
* */
val indexRangeInReversed: IntRange,
/** range of indexes inside LazyColumn where the first element is the split (it's index is [indexRangeInParentItems.first]) */
val indexRangeInParentItems: IntRange
)
data class ListItem(
val item: ChatItem,
val prevItem: ChatItem?,
val nextItem: ChatItem?,
// how many unread items before (older than) this one (excluding this one)
val unreadBefore: Int
)
data class ActiveChatState (
val splits: MutableStateFlow<List<Long>> = MutableStateFlow(emptyList()),
val unreadAfterItemId: MutableStateFlow<Long> = MutableStateFlow(-1L),
// total items after unread after item (exclusive)
val totalAfter: MutableStateFlow<Int> = MutableStateFlow(0),
val unreadTotal: MutableStateFlow<Int> = MutableStateFlow(0),
// exclusive
val unreadAfter: MutableStateFlow<Int> = MutableStateFlow(0),
// exclusive
val unreadAfterNewestLoaded: MutableStateFlow<Int> = MutableStateFlow(0)
) {
fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List<ChatItem>) {
toItemId ?: return
val currentIndex = nonReversedItems.indexOfFirst { it.id == unreadAfterItemId.value }
val newIndex = nonReversedItems.indexOfFirst { it.id == toItemId }
if (currentIndex == -1 || newIndex == -1) return
unreadAfterItemId.value = toItemId
val unreadDiff = if (newIndex > currentIndex) {
-nonReversedItems.subList(currentIndex + 1, newIndex + 1).count { it.isRcvNew }
} else {
nonReversedItems.subList(newIndex + 1, currentIndex + 1).count { it.isRcvNew }
}
unreadAfter.value += unreadDiff
}
fun moveUnreadAfterItem(fromIndex: Int, toIndex: Int, nonReversedItems: List<ChatItem>) {
if (fromIndex == -1 || toIndex == -1) return
unreadAfterItemId.value = nonReversedItems[toIndex].id
val unreadDiff = if (toIndex > fromIndex) {
-nonReversedItems.subList(fromIndex + 1, toIndex + 1).count { it.isRcvNew }
} else {
nonReversedItems.subList(toIndex + 1, fromIndex + 1).count { it.isRcvNew }
}
unreadAfter.value += unreadDiff
}
fun clear() {
splits.value = emptyList()
unreadAfterItemId.value = -1L
totalAfter.value = 0
unreadTotal.value = 0
unreadAfter.value = 0
unreadAfterNewestLoaded.value = 0
}
}
fun visibleItemIndexesNonReversed(mergedItems: State<MergedItems>, listState: LazyListState): IntRange {
val zero = 0 .. 0
if (listState.layoutInfo.totalItemsCount == 0) return zero
val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems
val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed()
if (newest == null || oldest == null) return zero
val size = chatModel.chatItems.value.size
val range = size - oldest .. size - newest
if (range.first < 0 || range.last < 0) return zero
// visible items mapped to their underlying data structure which is chatModel.chatItems
return range
}
fun recalculateChatStatePositions(chatState: ActiveChatState) = object: ChatItemsChangesListener {
override fun read(itemIds: Set<Long>?, newItems: List<ChatItem>) {
val (_, unreadAfterItemId, _, unreadTotal, unreadAfter) = chatState
if (itemIds == null) {
// special case when the whole chat became read
unreadTotal.value = 0
unreadAfter.value = 0
return
}
var unreadAfterItemIndex: Int = -1
// since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster
var i = newItems.lastIndex
val ids = itemIds.toMutableSet()
// intermediate variables to prevent re-setting state value a lot of times without reason
var newUnreadTotal = unreadTotal.value
var newUnreadAfter = unreadAfter.value
while (i >= 0) {
val item = newItems[i]
if (item.id == unreadAfterItemId.value) {
unreadAfterItemIndex = i
}
if (ids.contains(item.id)) {
// was unread, now this item is read
if (unreadAfterItemIndex == -1) {
newUnreadAfter--
}
newUnreadTotal--
ids.remove(item.id)
if (ids.isEmpty()) break
}
i--
}
unreadTotal.value = newUnreadTotal
unreadAfter.value = newUnreadAfter
}
override fun added(item: Pair<Long, Boolean>, index: Int) {
if (item.second) {
chatState.unreadAfter.value++
chatState.unreadTotal.value++
}
}
override fun removed(itemIds: List<Triple<Long, Int, Boolean>>, newItems: List<ChatItem>) {
val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter) = chatState
val newSplits = ArrayList<Long>()
for (split in splits.value) {
val index = itemIds.indexOfFirst { it.first == split }
// deleted the item that was right before the split between items, find newer item so it will act like the split
if (index != -1) {
val newSplit = newItems.getOrNull(itemIds[index].second - itemIds.count { it.second <= index })?.id
// it the whole section is gone and splits overlap, don't add it at all
if (newSplit != null && !newSplits.contains(newSplit)) {
newSplits.add(newSplit)
}
} else {
newSplits.add(split)
}
}
splits.value = newSplits
val index = itemIds.indexOfFirst { it.first == unreadAfterItemId.value }
// unread after item was removed
if (index != -1) {
var newUnreadAfterItemId = newItems.getOrNull(itemIds[index].second - itemIds.count { it.second <= index })?.id
val newUnreadAfterItemWasNull = newUnreadAfterItemId == null
if (newUnreadAfterItemId == null) {
// everything on top (including unread after item) were deleted, take top item as unread after id
newUnreadAfterItemId = newItems.firstOrNull()?.id
}
if (newUnreadAfterItemId != null) {
unreadAfterItemId.value = newUnreadAfterItemId
totalAfter.value -= itemIds.count { it.second > index }
unreadTotal.value -= itemIds.count { it.second <= index && it.third }
unreadAfter.value -= itemIds.count { it.second > index && it.third }
if (newUnreadAfterItemWasNull) {
// since the unread after item was moved one item after initial position, adjust counters accordingly
if (newItems.firstOrNull()?.isRcvNew == true) {
unreadTotal.value++
unreadAfter.value--
}
}
} else {
// all items were deleted, 0 items in chatItems
unreadAfterItemId.value = -1L
totalAfter.value = 0
unreadTotal.value = 0
unreadAfter.value = 0
}
} else {
totalAfter.value -= itemIds.size
}
}
override fun cleared() { chatState.clear() }
}
/** Helps in debugging */
//@Composable
//fun BoxScope.ShowChatState() {
// Box(Modifier.align(Alignment.Center).size(200.dp).background(Color.Black)) {
// val s = chatModel.chatState
// Text(
// "itemId ${s.unreadAfterItemId.value} / ${chatModel.chatItems.value.firstOrNull { it.id == s.unreadAfterItemId.value }?.text}, \nunreadAfter ${s.unreadAfter.value}, afterNewest ${s.unreadAfterNewestLoaded.value}",
// color = Color.White
// )
// }
//}
//// Returns items mapping for easy checking the structure
//fun MergedItems.mappingToString(): String = items.mapIndexed { index, g ->
// when (g) {
// is MergedItem.Single ->
// "\nstartIndexInParentItems $index, startIndexInReversedItems ${g.startIndexInReversedItems}, " +
// "revealed true, " +
// "mergeCategory null " +
// "\nunreadBefore ${g.item.unreadBefore}"
//
// is MergedItem.Grouped ->
// "\nstartIndexInParentItems $index, startIndexInReversedItems ${g.startIndexInReversedItems}, " +
// "revealed ${g.revealed}, " +
// "mergeCategory ${g.items[0].item.mergeCategory} " +
// g.items.mapIndexed { i, it ->
// "\nunreadBefore ${it.unreadBefore} ${Triple(index, g.startIndexInReversedItems + i, it.item.id)}"
// }
// }
//}.toString()
@@ -429,8 +429,8 @@ fun ComposeView(
ttl = ttl
)
chatItems?.forEach { chatItem ->
withChats {
withChats {
chatItems?.forEach { chatItem ->
addChatItem(rhId, chat.chatInfo, chatItem)
}
}
@@ -70,17 +70,9 @@ fun GroupMemberInfoView(
getContactChat = { chatModel.getContactChat(it) },
openDirectChat = {
withBGApi {
val c = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it)
if (c != null) {
withChats {
if (chatModel.getContactChat(it) == null) {
addChat(c)
}
chatModel.chatItemStatuses.clear()
chatModel.chatItems.replaceAll(c.chatItems)
chatModel.chatId.value = c.id
closeAll()
}
apiLoadMessages(rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState)
if (chatModel.getContactChat(it) != null) {
closeAll()
}
}
},
@@ -92,7 +84,7 @@ fun GroupMemberInfoView(
val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf())
withChats {
addChat(memberChat)
openLoadedChat(memberChat, chatModel)
openLoadedChat(memberChat)
}
closeAll()
chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected())
@@ -21,7 +21,7 @@ fun CIChatFeatureView(
feature: Feature,
iconColor: Color,
icon: Painter? = null,
revealed: MutableState<Boolean>,
revealed: State<Boolean>,
showMenu: MutableState<Boolean>,
) {
val merged = if (!revealed.value) mergedFeatures(chatItem, chatInfo) else emptyList()
@@ -431,6 +431,9 @@ fun VideoPreviewImageViewFullScreen(preview: ImageBitmap, onClick: () -> Unit, o
@Composable
expect fun LocalWindowWidth(): Dp
@Composable
expect fun LocalWindowHeight(): Dp
@Composable
private fun progressIndicator() {
CircularProgressIndicator(
@@ -2,6 +2,8 @@ package chat.simplex.common.views.chat.item
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.material.*
@@ -56,8 +58,9 @@ fun ChatItemView(
imageProvider: (() -> ImageGalleryProvider)? = null,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
revealed: MutableState<Boolean>,
range: IntRange?,
revealed: State<Boolean>,
highlighted: State<Boolean>,
range: State<IntRange?>,
selectedChatItems: MutableState<Set<Long>?>,
fillMaxWidth: Boolean = true,
selectChatItem: () -> Unit,
@@ -79,6 +82,7 @@ fun ChatItemView(
findModelMember: (String) -> GroupMember?,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
reveal: (Boolean) -> Unit,
developerTools: Boolean,
showViaProxy: Boolean,
showTimestamp: Boolean,
@@ -91,7 +95,7 @@ fun ChatItemView(
val showMenu = remember { mutableStateOf(false) }
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
val onLinkLongClick = { _: String -> showMenu.value = true }
val live = composeState.value.liveMessage != null
val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value
Box(
modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier,
@@ -134,10 +138,19 @@ fun ChatItemView(
}
Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) {
val interactionSource = remember { MutableInteractionSource() }
val enterInteraction = remember { HoverInteraction.Enter() }
KeyChangeEffect(highlighted.value) {
if (highlighted.value) {
interactionSource.emit(enterInteraction)
} else {
interactionSource.emit(HoverInteraction.Exit(enterInteraction))
}
}
Column(
Modifier
.clipChatItem(cItem, itemSeparation.largeGap, revealed.value)
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick, interactionSource = interactionSource, indication = LocalIndication.current)
.onRightClick { showMenu.value = true },
) {
@Composable
@@ -275,7 +288,7 @@ fun ChatItemView(
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
if (revealed.value) {
HideItemAction(revealed, showMenu)
HideItemAction(revealed, showMenu, reveal)
}
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null && !cItem.localNote) {
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
@@ -296,11 +309,11 @@ fun ChatItemView(
cItem.meta.itemDeleted != null -> {
DefaultDropdownMenu(showMenu) {
if (revealed.value) {
HideItemAction(revealed, showMenu)
HideItemAction(revealed, showMenu, reveal)
} else if (!cItem.isDeletedContent) {
RevealItemAction(revealed, showMenu)
} else if (range != null) {
ExpandItemAction(revealed, showMenu)
RevealItemAction(revealed, showMenu, reveal)
} else if (range.value != null) {
ExpandItemAction(revealed, showMenu, reveal)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
@@ -320,12 +333,12 @@ fun ChatItemView(
}
}
}
cItem.mergeCategory != null && ((range?.count() ?: 0) > 1 || revealed.value) -> {
cItem.mergeCategory != null && ((range.value?.count() ?: 0) > 1 || revealed.value) -> {
DefaultDropdownMenu(showMenu) {
if (revealed.value) {
ShrinkItemAction(revealed, showMenu)
ShrinkItemAction(revealed, showMenu, reveal)
} else {
ExpandItemAction(revealed, showMenu)
ExpandItemAction(revealed, showMenu, reveal)
}
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
if (cItem.canBeDeletedForSelf) {
@@ -350,7 +363,7 @@ fun ChatItemView(
fun MarkedDeletedItemDropdownMenu() {
DefaultDropdownMenu(showMenu) {
if (!cItem.isDeletedContent) {
RevealItemAction(revealed, showMenu)
RevealItemAction(revealed, showMenu, reveal)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
@@ -623,7 +636,7 @@ fun ItemInfoAction(
@Composable
fun DeleteItemAction(
cItem: ChatItem,
revealed: MutableState<Boolean>,
revealed: State<Boolean>,
showMenu: MutableState<Boolean>,
questionText: String,
deleteMessage: (Long, CIDeleteMode) -> Unit,
@@ -700,48 +713,48 @@ fun SelectItemAction(
}
@Composable
private fun RevealItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
private fun RevealItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, reveal: (Boolean) -> Unit) {
ItemAction(
stringResource(MR.strings.reveal_verb),
painterResource(MR.images.ic_visibility),
onClick = {
revealed.value = true
reveal(true)
showMenu.value = false
}
)
}
@Composable
private fun HideItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
private fun HideItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, reveal: (Boolean) -> Unit) {
ItemAction(
stringResource(MR.strings.hide_verb),
painterResource(MR.images.ic_visibility_off),
onClick = {
revealed.value = false
reveal(false)
showMenu.value = false
}
)
}
@Composable
private fun ExpandItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
private fun ExpandItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, reveal: (Boolean) -> Unit) {
ItemAction(
stringResource(MR.strings.expand_verb),
painterResource(MR.images.ic_expand_all),
onClick = {
revealed.value = true
reveal(true)
showMenu.value = false
},
)
}
@Composable
private fun ShrinkItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
private fun ShrinkItemAction(revealed: State<Boolean>, showMenu: MutableState<Boolean>, reveal: (Boolean) -> Unit) {
ItemAction(
stringResource(MR.strings.hide_verb),
painterResource(MR.images.ic_collapse_all),
onClick = {
revealed.value = false
reveal(false)
showMenu.value = false
},
)
@@ -1063,7 +1076,8 @@ fun PreviewChatItemView(
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
revealed = remember { mutableStateOf(false) },
range = 0..1,
highlighted = remember { mutableStateOf(false) },
range = remember { mutableStateOf(0..1) },
selectedChatItems = remember { mutableStateOf(setOf()) },
selectChatItem = {},
deleteMessage = { _, _ -> },
@@ -1084,6 +1098,7 @@ fun PreviewChatItemView(
findModelMember = { null },
setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> },
reveal = {},
developerTools = false,
showViaProxy = false,
showTimestamp = true,
@@ -1104,7 +1119,8 @@ fun PreviewChatItemViewDeletedContent() {
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
revealed = remember { mutableStateOf(false) },
range = 0..1,
highlighted = remember { mutableStateOf(false) },
range = remember { mutableStateOf(0..1) },
selectedChatItems = remember { mutableStateOf(setOf()) },
selectChatItem = {},
deleteMessage = { _, _ -> },
@@ -1125,6 +1141,7 @@ fun PreviewChatItemViewDeletedContent() {
findModelMember = { null },
setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> },
reveal = {},
developerTools = false,
showViaProxy = false,
preview = true,
@@ -129,7 +129,14 @@ fun FramedItemView(
.fillMaxWidth()
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = { scrollToItem(qi.itemId?: return@combinedClickable) }
onClick = {
val itemId = qi.itemId
if (itemId != null) {
scrollToItem(itemId)
} else {
showQuotedItemDoesNotExistAlert()
}
}
)
.onRightClick { showMenu.value = true }
) {
@@ -465,6 +472,13 @@ fun CenteredRowLayout(
}
}
fun showQuotedItemDoesNotExistAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.message_deleted_or_not_received_error_title),
text = generalGetString(MR.strings.message_deleted_or_not_received_error_desc)
)
}
/*
class EditedProvider: PreviewParameterProvider<Boolean> {
@@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.stringResource
import kotlinx.datetime.Clock
@Composable
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
val sentColor = MaterialTheme.appColors.sentMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Surface(
@@ -41,7 +41,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: Mutabl
}
@Composable
private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: MutableState<Boolean>) {
private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>) {
var i = getChatItemIndexOrNull(chatItem)
val ciCategory = chatItem.mergeCategory
val text = if (!revealed.value && ciCategory != null && i != null) {
@@ -1,7 +1,6 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -14,6 +13,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
@@ -33,6 +33,7 @@ import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import kotlin.math.min
@Composable
fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
@@ -71,7 +72,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false)
}
},
click = { scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } },
click = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } },
dropdownMenuItems = {
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead)
@@ -90,7 +91,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout)
}
},
click = { if (!inProgress.value) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } },
click = { if (!inProgress.value && chatModel.chatId.value != chat.id) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } },
dropdownMenuItems = {
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead)
@@ -108,7 +109,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false)
}
},
click = { scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } },
click = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } },
dropdownMenuItems = {
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
NoteFolderMenuItems(chat, showMenu, showMarkRead)
@@ -187,7 +188,7 @@ fun ErrorChatListItem() {
suspend fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) {
when {
contact.activeConn == null && contact.profile.contactLink != null && contact.active -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true)
else -> openChat(rhId, ChatInfo.Direct(contact), chatModel)
else -> openChat(rhId, ChatInfo.Direct(contact))
}
}
@@ -195,54 +196,31 @@ suspend fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel, inProgress)
GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert(rhId)
else -> openChat(rhId, ChatInfo.Group(groupInfo), chatModel)
else -> openChat(rhId, ChatInfo.Group(groupInfo))
}
}
suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) {
openChat(rhId, ChatInfo.Local(noteFolder), chatModel)
}
suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat(rhId, ChatInfo.Local(noteFolder))
suspend fun openDirectChat(rhId: Long?, contactId: Long, chatModel: ChatModel) = coroutineScope {
val chat = chatModel.controller.apiGetChat(rhId, ChatType.Direct, contactId)
if (chat != null && isActive) {
openLoadedChat(chat, chatModel)
}
}
suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(rhId, ChatType.Direct, contactId)
suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) = coroutineScope {
val chat = chatModel.controller.apiGetChat(rhId, ChatType.Group, groupId)
if (chat != null && isActive) {
openLoadedChat(chat, chatModel)
}
}
suspend fun openGroupChat(rhId: Long?, groupId: Long) = openChat(rhId, ChatType.Group, groupId)
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) = coroutineScope {
val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId)
if (chat != null && isActive) {
openLoadedChat(chat, chatModel)
}
}
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo) = openChat(rhId, chatInfo.chatType, chatInfo.apiId)
fun openLoadedChat(chat: Chat, chatModel: ChatModel) {
private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long) =
apiLoadMessages(rhId, chatType, apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState)
fun openLoadedChat(chat: Chat) {
chatModel.chatItemStatuses.clear()
chatModel.chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id
chatModel.chatState.clear()
}
suspend fun apiLoadPrevMessages(ch: Chat, chatModel: ChatModel, beforeChatItemId: Long, search: String) {
val chatInfo = ch.chatInfo
val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT)
val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return
if (chatModel.chatId.value != chat.id) return
chatModel.chatItems.addAll(0, chat.chatItems)
}
suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) {
val chatInfo = ch.chatInfo
val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return
if (chatModel.chatId.value != chat.id) return
chatModel.chatItems.replaceAll(chat.chatItems)
suspend fun apiFindMessages(ch: Chat, search: String) {
chatModel.chatItems.clearAndNotify()
apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState, search = search)
}
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) {
@@ -724,7 +702,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress(
close?.invoke()
val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false)
if (ok && openChat) {
openDirectChat(rhId, contact.contactId, chatModel)
openDirectChat(rhId, contact.contactId)
}
}
}) {
@@ -736,7 +714,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress(
close?.invoke()
val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true)
if (ok && openChat) {
openDirectChat(rhId, contact.contactId, chatModel)
openDirectChat(rhId, contact.contactId)
}
}
}) {
@@ -25,17 +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
@@ -72,58 +76,39 @@ private fun showNewChatSheet(oneHandUI: State<Boolean>) {
@Composable
fun ToggleChatListCard() {
Column(
modifier = Modifier
.padding(16.dp)
.clip(RoundedCornerShape(18.dp))
ChatListCard(
close = {
appPrefs.oneHandUICardShown.set(true)
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.one_hand_ui),
text = generalGetString(MR.strings.one_hand_ui_change_instruction),
)
}
) {
Box(
Column(
modifier = Modifier
.background(MaterialTheme.appColors.sentMessage)
.padding(horizontal = DEFAULT_PADDING)
.padding(top = DEFAULT_PADDING)
) {
Box(
modifier = Modifier.fillMaxWidth().matchParentSize().padding(5.dp),
contentAlignment = Alignment.TopEnd
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
IconButton(
onClick = {
appPrefs.oneHandUICardShown.set(true)
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.one_hand_ui),
text = generalGetString(MR.strings.one_hand_ui_change_instruction),
)
}
) {
Icon(
painterResource(MR.images.ic_close), stringResource(MR.strings.back), tint = MaterialTheme.colors.secondary
)
}
Text(stringResource(MR.strings.one_hand_ui_card_title), style = MaterialTheme.typography.h3)
}
Column(
modifier = Modifier
.padding(horizontal = DEFAULT_PADDING)
.padding(top = DEFAULT_PADDING)
Row(
Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(MR.strings.one_hand_ui_card_title), style = MaterialTheme.typography.h3)
}
Row(
Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(MR.strings.one_hand_ui), Modifier.weight(10f), style = MaterialTheme.typography.body1)
Text(stringResource(MR.strings.one_hand_ui), Modifier.weight(10f), style = MaterialTheme.typography.body1)
Spacer(Modifier.fillMaxWidth().weight(1f))
Spacer(Modifier.fillMaxWidth().weight(1f))
SharedPreferenceToggle(
appPrefs.oneHandUI,
enabled = true
)
}
SharedPreferenceToggle(
appPrefs.oneHandUI,
enabled = true
)
}
}
}
@@ -132,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)
}
}
}
@@ -196,6 +197,94 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
}
}
@Composable
private fun ChatListCard(
close: () -> Unit,
onCardClick: (() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit
) {
Column(
modifier = Modifier.clip(RoundedCornerShape(18.dp))
) {
Box(
modifier = Modifier
.background(MaterialTheme.appColors.sentMessage)
.clickable {
onCardClick?.invoke()
}
) {
Box(
modifier = Modifier.fillMaxWidth().matchParentSize().padding(5.dp),
contentAlignment = Alignment.TopEnd
) {
IconButton(
onClick = {
close()
}
) {
Icon(
painterResource(MR.images.ic_close), stringResource(MR.strings.back), tint = MaterialTheme.colors.secondary
)
}
}
content()
}
}
}
@Composable
private fun AddressCreationCard() {
ChatListCard(
close = {
appPrefs.addressCreationCardShown.set(true)
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.simplex_address),
text = generalGetString(MR.strings.address_creation_instruction),
)
},
onCardClick = {
ModalManager.start.showModal {
UserAddressLearnMore(showCreateAddressButton = true)
}
}
) {
Box(modifier = Modifier.matchParentSize().padding(end = (DEFAULT_PADDING_HALF + 2.dp) * fontSizeSqrtMultiplier, bottom = 2.dp), contentAlignment = Alignment.BottomEnd) {
TextButton(
onClick = {
ModalManager.start.showModalCloseable { close ->
UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, close = close)
}
},
) {
Text(stringResource(MR.strings.create_address_button), style = MaterialTheme.typography.body1)
}
}
Row(
Modifier
.fillMaxWidth()
.padding(DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.padding(vertical = 4.dp)) {
Box(Modifier.background(MaterialTheme.colors.primary, CircleShape).padding(12.dp)) {
ProfileImage(size = 37.dp, null, icon = MR.images.ic_mail_filled, color = Color.White, backgroundColor = Color.Red)
}
}
Column(modifier = Modifier.padding(start = DEFAULT_PADDING)) {
Text(stringResource(MR.strings.your_simplex_contact_address), style = MaterialTheme.typography.h3)
Spacer(Modifier.fillMaxWidth().padding(DEFAULT_PADDING_HALF))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(MR.strings.how_to_use_simplex_chat), Modifier.padding(end = DEFAULT_SPACE_AFTER_ICON), style = MaterialTheme.typography.body1)
Icon(
painterResource(MR.images.ic_info),
null,
)
}
}
}
}
}
@Composable
private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState<TextFieldValue>, listState: LazyListState) {
if (!chatModel.desktopNoUserNoRemote) {
@@ -641,6 +730,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
val keyboardState by getKeyboardState()
val oneHandUI = remember { appPrefs.oneHandUI.state }
val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state }
val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state }
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
val currentIndex = listState.firstVisibleItemIndex
@@ -709,20 +799,15 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
}
}
}
if (!oneHandUICardShown.value && chats.size > 1) {
item {
ToggleChatListCard()
}
}
itemsIndexed(chats, key = { _, chat -> chat.remoteHostId to chat.id }) { index, chat ->
val nextChatSelected = remember(chat.id, chats) { derivedStateOf {
chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value
} }
ChatListNavLinkView(chat, nextChatSelected)
}
if (!oneHandUICardShown.value && chats.size <= 1) {
if (!oneHandUICardShown.value || !addressCreationCardShown.value) {
item {
ToggleChatListCard()
ChatListFeatureCards()
}
}
if (appPlatform.isAndroid) {
@@ -741,7 +826,36 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
}
if (!oneHandUICardShown.value) {
LaunchedEffect(chats.size) {
if (chats.size >= 3) appPrefs.oneHandUICardShown.set(true)
if (chats.size >= 3) {
appPrefs.oneHandUICardShown.set(true)
}
}
}
if (!addressCreationCardShown.value) {
LaunchedEffect(chatModel.userAddress.value) {
if (chatModel.userAddress.value != null) {
appPrefs.addressCreationCardShown.set(true)
}
}
}
}
@Composable
private fun ChatListFeatureCards() {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state }
val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state }
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (!oneHandUICardShown.value && !oneHandUI.value) {
ToggleChatListCard()
}
if (!addressCreationCardShown.value) {
AddressCreationCard()
}
if (!oneHandUICardShown.value && oneHandUI.value) {
ToggleChatListCard()
}
}
}
@@ -231,7 +231,7 @@ fun ChatPreviewView(
fun chatItemContentPreview(chat: Chat, ci: ChatItem?) {
val mc = ci?.content?.msgContent
val provider by remember(chat.id, ci?.id, ci?.file?.fileStatus) {
mutableStateOf({ providerForGallery(0, chat.chatItems, ci?.id ?: 0) {} })
mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} })
}
val uriHandler = LocalUriHandler.current
when (mc) {
@@ -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()
}
@@ -21,7 +21,7 @@ fun onRequestAccepted(chat: Chat) {
if (chatInfo is ChatInfo.Direct) {
ModalManager.start.closeModals()
if (chatInfo.contact.sndReady) {
openLoadedChat(chat, chatModel)
openLoadedChat(chat)
}
}
}
@@ -54,13 +54,13 @@ fun ContactListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>, showDel
when (contactType) {
ContactType.RECENT -> {
withApi {
openChat(rhId, chat.chatInfo, chatModel)
openChat(rhId, chat.chatInfo)
ModalManager.start.closeModals()
}
}
ContactType.CHAT_DELETED -> {
withApi {
openChat(rhId, chat.chatInfo, chatModel)
openChat(rhId, chat.chatInfo)
ModalManager.start.closeModals()
}
}
@@ -500,7 +500,7 @@ fun deleteChatDatabaseFilesAndState() {
// Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself
chatModel.chatId.value = null
chatModel.chatItems.clear()
chatModel.chatItems.clearAndNotify()
withLongRunningApi {
withChats {
chats.clear()
@@ -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
@@ -44,7 +44,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c
if (groupInfo != null) {
withChats {
updateGroup(rhId = rhId, groupInfo)
chatModel.chatItems.clear()
chatModel.chatItems.clearAndNotify()
chatModel.chatItemStatuses.clear()
chatModel.chatId.value = groupInfo.id
}
@@ -409,7 +409,7 @@ fun openKnownContact(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, co
val c = chatModel.getContactChat(contact.contactId)
if (c != null) {
close?.invoke()
openDirectChat(rhId, contact.contactId, chatModel)
openDirectChat(rhId, contact.contactId)
}
}
}
@@ -490,7 +490,7 @@ fun openKnownGroup(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, grou
val g = chatModel.getGroupChat(groupInfo.groupId)
if (g != null) {
close?.invoke()
openGroupChat(rhId, groupInfo.groupId, chatModel)
openGroupChat(rhId, groupInfo.groupId)
}
}
}
@@ -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()
}
}
@@ -1,197 +0,0 @@
package chat.simplex.common.views.onboarding
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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 androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import dev.icerock.moko.resources.compose.painterResource
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.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.SimpleXLinkQRCode
import chat.simplex.common.views.newchat.simplexChatLink
import chat.simplex.res.MR
@Composable
fun CreateSimpleXAddress(m: ChatModel, rhId: Long?) {
var progressIndicator by remember { mutableStateOf(false) }
val userAddress = remember { m.userAddress }
val clipboard = LocalClipboardManager.current
val uriHandler = LocalUriHandler.current
LaunchedEffect(Unit) {
prepareChatBeforeAddressCreation(rhId)
}
CreateSimpleXAddressLayout(
userAddress.value,
share = { address: String -> clipboard.shareText(address) },
sendEmail = { address ->
uriHandler.sendEmail(
generalGetString(MR.strings.email_invite_subject),
generalGetString(MR.strings.email_invite_body).format(simplexChatLink(address.connReqContact))
)
},
createAddress = {
withBGApi {
progressIndicator = true
val connReqContact = m.controller.apiCreateUserAddress(rhId)
if (connReqContact != null) {
m.userAddress.value = UserContactLinkRec(connReqContact)
progressIndicator = false
}
}
},
nextStep = {
val next = if (appPlatform.isAndroid) {
OnboardingStage.Step4_SetNotificationsMode
} else {
OnboardingStage.OnboardingComplete
}
m.controller.appPrefs.onboardingStage.set(next)
},
)
if (progressIndicator) {
ProgressIndicator()
}
}
@Composable
private fun CreateSimpleXAddressLayout(
userAddress: UserContactLinkRec?,
share: (String) -> Unit,
sendEmail: (UserContactLinkRec) -> Unit,
createAddress: () -> Unit,
nextStep: () -> Unit,
) {
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ModalView({}, showClose = false) {
ColumnWithScrollBar(
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarTitle(stringResource(MR.strings.simplex_address))
Spacer(Modifier.weight(1f))
if (userAddress != null) {
SimpleXLinkQRCode(userAddress.connReqContact)
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
Row {
ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) }
Spacer(Modifier.width(DEFAULT_PADDING * 2))
ShareViaEmailButton { sendEmail(userAddress) }
}
Spacer(Modifier.height(DEFAULT_PADDING))
Spacer(Modifier.weight(1f))
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.continue_to_next_step,
onboarding = null,
onclick = nextStep
)
// Reserve space
TextButtonBelowOnboardingButton("", null)
}
} else {
Button(createAddress, Modifier, shape = CircleShape, contentPadding = PaddingValues()) {
Icon(painterResource(MR.images.ic_mail_filled), null, Modifier.size(100.dp).background(MaterialTheme.colors.primary, CircleShape).padding(25.dp), tint = Color.White)
}
Spacer(Modifier.height(DEFAULT_PADDING))
Spacer(Modifier.weight(1f))
Text(stringResource(MR.strings.create_simplex_address), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold)
TextBelowButton(stringResource(MR.strings.you_can_make_address_visible_via_settings))
Spacer(Modifier.height(DEFAULT_PADDING))
Spacer(Modifier.weight(1f))
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.create_address_button,
onboarding = null,
onclick = createAddress
)
TextButtonBelowOnboardingButton(stringResource(MR.strings.dont_create_address), nextStep)
}
}
}
}
}
}
@Composable
fun ShareAddressButton(onClick: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
IconButton(onClick, Modifier.padding(bottom = DEFAULT_PADDING_HALF).border(1.dp, MaterialTheme.colors.secondary.copy(0.1f), CircleShape)) {
Icon(
painterResource(MR.images.ic_share_filled), generalGetString(MR.strings.share_verb), tint = MaterialTheme.colors.primary,
modifier = Modifier.size(50.dp).padding(DEFAULT_PADDING_HALF)
)
}
Text(stringResource(MR.strings.share_verb))
}
}
@Composable
fun ShareViaEmailButton(onClick: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
IconButton(onClick, Modifier.padding(bottom = DEFAULT_PADDING_HALF).border(1.dp, MaterialTheme.colors.secondary.copy(0.1f), CircleShape)) {
Icon(
painterResource(MR.images.ic_mail), generalGetString(MR.strings.share_verb), tint = MaterialTheme.colors.primary,
modifier = Modifier.size(50.dp).padding(DEFAULT_PADDING_HALF)
)
}
Text(stringResource(MR.strings.invite_friends_short))
}
}
@Composable
private fun TextBelowButton(text: String) {
Text(
text,
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING * 3, vertical = DEFAULT_PADDING_HALF),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.secondary,
textAlign = TextAlign.Center,
)
}
@Composable
private fun ProgressIndicator() {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
}
}
private fun prepareChatBeforeAddressCreation(rhId: Long?) {
// 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 {
val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withBGApi
chatModel.currentUser.value = user
chatModel.controller.startChat(user)
}
}
@@ -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
@@ -25,6 +24,10 @@ import chat.simplex.res.MR
@Composable
fun SetNotificationsMode(m: ChatModel) {
LaunchedEffect(Unit) {
prepareChatBeforeFinishingOnboarding()
}
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ModalView({}, showClose = false) {
ColumnWithScrollBar(Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
@@ -53,6 +56,7 @@ fun SetNotificationsMode(m: ChatModel) {
onboarding = OnboardingStage.OnboardingComplete,
onclick = {
changeNotificationsMode(currentMode.value, m)
ModalManager.fullscreen.closeModals()
}
)
// Reserve space
@@ -94,3 +98,13 @@ fun <T> SelectableCard(currentValue: State<T>, newValue: T, title: String, descr
}
Spacer(Modifier.height(14.dp))
}
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 {
val user = chatModel.controller.apiGetActiveUser(null) ?: return@withBGApi
chatModel.currentUser.value = user
chatModel.controller.startChat(user)
}
}
@@ -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.Step3_CreateSimpleXAddress)
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,9 +1,16 @@
package chat.simplex.common.views.usersettings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.views.helpers.*
@@ -12,12 +19,30 @@ import chat.simplex.common.views.onboarding.ReadableTextWithLink
import chat.simplex.res.MR
@Composable
fun UserAddressLearnMore() {
fun UserAddressLearnMore(showCreateAddressButton: Boolean = false) {
ColumnWithScrollBar(Modifier .padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.simplex_address), withPadding = false)
ReadableText(MR.strings.you_can_share_your_address)
ReadableText(MR.strings.you_wont_lose_your_contacts_if_delete_address)
ReadableText(MR.strings.you_can_accept_or_reject_connection)
ReadableTextWithLink(MR.strings.read_more_in_user_guide_with_link, "https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address")
if (showCreateAddressButton) {
Spacer(Modifier.weight(1f))
Column(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING * 2), horizontalAlignment = Alignment.CenterHorizontally) {
Button(
onClick = {
ModalManager.start.showModalCloseable { close ->
UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, close = close)
}
},
shape = CircleShape,
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 2, vertical = DEFAULT_PADDING),
colors = ButtonDefaults.buttonColors(MaterialTheme.colors.primary, disabledBackgroundColor = MaterialTheme.colors.secondary)
) {
Text(stringResource(MR.strings.create_simplex_address), style = MaterialTheme.typography.h2, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium)
}
}
}
}
}
@@ -33,6 +33,7 @@ fun UserAddressView(
chatModel: ChatModel,
viaCreateLinkView: Boolean = false,
shareViaProfile: Boolean = false,
autoCreateAddress: Boolean = false,
close: () -> Unit
) {
// TODO close when remote host changes
@@ -58,6 +59,33 @@ fun UserAddressView(
}
}
}
fun createAddress() {
withBGApi {
progressIndicator = true
val connReqContact = chatModel.controller.apiCreateUserAddress(user?.value?.remoteHostId)
if (connReqContact != null) {
chatModel.userAddress.value = UserContactLinkRec(connReqContact)
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.share_address_with_contacts_question),
text = generalGetString(MR.strings.add_address_to_your_profile),
confirmText = generalGetString(MR.strings.share_verb),
onConfirm = {
setProfileAddress(true)
shareViaProfile.value = true
}
)
}
progressIndicator = false
}
}
LaunchedEffect(autoCreateAddress) {
if (autoCreateAddress) {
createAddress()
}
}
val userAddress = remember { chatModel.userAddress }
val clipboard = LocalClipboardManager.current
val uriHandler = LocalUriHandler.current
@@ -67,26 +95,7 @@ fun UserAddressView(
userAddress = userAddress.value,
shareViaProfile,
onCloseHandler,
createAddress = {
withBGApi {
progressIndicator = true
val connReqContact = chatModel.controller.apiCreateUserAddress(user?.value?.remoteHostId)
if (connReqContact != null) {
chatModel.userAddress.value = UserContactLinkRec(connReqContact)
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.share_address_with_contacts_question),
text = generalGetString(MR.strings.add_address_to_your_profile),
confirmText = generalGetString(MR.strings.share_verb),
onConfirm = {
setProfileAddress(true)
shareViaProfile.value = true
}
)
}
progressIndicator = false
}
},
createAddress = { createAddress() },
learnMore = {
ModalManager.start.showModal {
UserAddressLearnMore()
@@ -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 &amp; file servers.</string>
<string name="no_media_servers_configured_for_sending">No servers to send files.</string>
<string name="no_media_servers_configured_for_private_routing">No servers to receive files.</string>
<string name="for_chat_profile">For chat profile %s:</string>
<string name="errors_in_servers_configuration">Errors in servers configuration.</string>
<string name="error_accepting_operator_conditions">Error accepting conditions</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Connection timeout</string>
@@ -276,6 +286,8 @@
<string name="message_delivery_error_title">Message delivery error</string>
<string name="message_delivery_warning_title">Message delivery warning</string>
<string name="message_delivery_error_desc">Most likely this contact has deleted the connection with you.</string>
<string name="message_deleted_or_not_received_error_title">No message</string>
<string name="message_deleted_or_not_received_error_desc">This message was deleted or not received yet.</string>
<!-- CIStatus errors -->
<string name="ci_status_other_error">Error: %1$s</string>
@@ -378,6 +390,7 @@
<string name="contact_tap_to_connect">Tap to Connect</string>
<string name="connect_with_contact_name_question">Connect with %1$s?</string>
<string name="search_or_paste_simplex_link">Search or paste SimpleX link</string>
<string name="address_creation_instruction">Tap Create SimpleX address in the menu to create it later.</string>
<!-- ChatView.kt -->
<string name="no_selected_chat">No selected chat</string>
@@ -747,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>
@@ -1035,6 +1049,19 @@
<string name="you_can_change_it_later">Random passphrase is stored in settings as plaintext.\nYou can change it later.</string>
<string name="use_random_passphrase">Use random passphrase</string>
<!-- ChooseServerOperators.kt -->
<string name="onboarding_choose_server_operators">Choose operators</string>
<string name="onboarding_network_operators">Network operators</string>
<string name="onboarding_network_operators_app_will_use_different_operators">When more than one network operator is enabled, the app will use the servers of different operators for each conversation.</string>
<string name="onboarding_network_operators_app_will_use_for_routing">For example, if you receive messages via SimpleX Chat server, the app will use one of Flux servers for private routing.</string>
<string name="onboarding_select_network_operators_to_use">Select network operators to use.</string>
<string name="onboarding_network_operators_configure_via_settings">You can configure servers via settings.</string>
<string name="onboarding_network_operators_conditions_will_be_accepted">Conditions will be accepted for enabled operators after 30 days.</string>
<string name="onboarding_network_operators_conditions_you_can_configure">You can configure operators in Network &amp; servers settings.</string>
<string name="onboarding_network_operators_review_later">Review later</string>
<string name="onboarding_network_operators_update">Update</string>
<string name="onboarding_network_operators_continue">Continue</string>
<!-- Call -->
<string name="incoming_video_call">Incoming video call</string>
<string name="incoming_audio_call">Incoming audio call</string>
@@ -1664,6 +1691,59 @@
<string name="save_group_profile">Save group profile</string>
<string name="error_saving_group_profile">Error saving group profile</string>
<!-- NetworkAndServers.kt -->
<string name="network_preset_servers_title">Preset servers</string>
<string name="operator_review_conditions">Review conditions</string>
<string name="operator_conditions_accepted">Accepted conditions</string>
<string name="operator_conditions_accepted_for_enabled_operators_on">Conditions will be automatically accepted for enabled operators on: %s.</string>
<string name="your_servers">Your servers</string>
<string name="operators_conditions_accepted_for"><![CDATA[Conditions are accepted for the operator(s): <b>%s</b>.]]></string>
<string name="operators_conditions_will_be_accepted_for"><![CDATA[Conditions will be accepted for the operator(s): <b>%s</b>.]]></string>
<!-- OperatorView.kt -->
<string name="operator">Operator</string>
<string name="operator_servers_title">%s servers</string>
<string name="operator_info_title">Network operator</string>
<string name="operator_website">Website</string>
<string name="operator_conditions_accepted_on">Conditions accepted on: %s.</string>
<string name="operator_conditions_will_be_accepted_on">Conditions will be accepted on: %s.</string>
<string name="operator">Operator</string>
<string name="operator_use_operator_toggle_description">Use servers</string>
<string name="use_servers_of_operator_x">Use %s</string>
<string name="operator_conditions_failed_to_load">Current conditions text couldn\'t be loaded, you can review conditions via this link:</string>
<string name="operator_conditions_accepted_for_some"><![CDATA[Conditions are already accepted for following operator(s): <b>%s</b>.]]></string>
<string name="operator_same_conditions_will_be_applied"><![CDATA[Same conditions will apply to operator <b>%s</b>.]]></string>
<string name="operator_same_conditions_will_apply_to_operators"><![CDATA[Same conditions will apply to operator(s): <b>%s</b>.]]></string>
<string name="operator_conditions_accepted_for_some"><![CDATA[Conditions are already accepted for following operator(s): <b>%s</b>.]]></string>
<string name="operator_conditions_will_be_applied"><![CDATA[These conditions will also apply for: <b>%s</b>.]]></string>
<string name="operator_same_conditions_will_be_applied"><![CDATA[Same conditions will apply to operator: <b>%s</b>.]]></string>
<string name="operator_conditions_will_be_accepted_for_some"><![CDATA[Conditions will be accepted for operator(s): <b>%s</b>.]]></string>
<string name="operators_conditions_will_also_apply"><![CDATA[These conditions will also apply for: <b>%s</b>.]]></string>
<string name="view_conditions">View conditions</string>
<string name="accept_conditions">Accept conditions</string>
<string name="operator_conditions_of_use">Conditions of use</string>
<string name="operator_in_order_to_use_accept_conditions"><![CDATA[To use the servers of <b>%s</b>, accept conditions of use.]]></string>
<string name="operator_use_for_messages">Use for messages</string>
<string name="operator_use_for_messages_receiving">To receive</string>
<string name="operator_use_for_messages_private_routing">For private routing</string>
<string name="operator_added_message_servers">Added message servers</string>
<string name="operator_use_for_files">Use for files</string>
<string name="operator_use_for_sending">To send</string>
<string name="xftp_servers_per_user">The servers for new files of your current chat profile</string>
<string name="operator_added_xftp_servers">Added media &amp; file servers</string>
<string name="operator_open_conditions">Open conditions</string>
<string name="operator_open_changes">Open changes</string>
<!-- ProtocolServerView.kt -->
<string name="error_updating_server_title">Error updating server</string>
<string name="error_server_protocol_changed">Server protocol changed.</string>
<string name="error_server_operator_changed">Server operator changed.</string>
<!-- NewServerView.kt -->
<string name="operator_server_alert_title">Operator server</string>
<string name="server_added_to_operator__name">Server added to operator %s.</string>
<string name="error_adding_server">Error adding server</string>
<!-- AdvancedNetworkSettings.kt -->
<string name="network_option_tcp_connection">TCP connection</string>
<string name="network_options_reset_to_defaults">Reset to defaults</string>
@@ -2056,6 +2136,13 @@
<string name="v6_1_message_dates_descr">Better message dates.</string>
<string name="v6_1_forward_many_messages_descr">Forward up to 20 messages at once.</string>
<string name="v6_1_delete_many_messages_descr">Delete or moderate up to 200 messages.</string>
<string name="v6_2_network_decentralization">Network decentralization</string>
<string name="v6_2_network_decentralization_descr">The second preset operator in the app!</string>
<string name="v6_2_network_decentralization_enable_flux">Enable flux</string>
<string name="v6_2_network_decentralization_enable_flux_reason">for better metadata privacy.</string>
<string name="v6_2_improved_chat_navigation">Improved chat navigation</string>
<string name="v6_2_improved_chat_navigation_descr">- Open chat on the first unread message.\n- Jump to quoted messages.</string>
<string name="view_updated_conditions">View updated conditions</string>
<!-- CustomTimePicker -->
<string name="custom_time_unit_seconds">seconds</string>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -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

@@ -0,0 +1,158 @@
package chat.simplex.app
import androidx.compose.runtime.mutableStateOf
import chat.simplex.common.model.*
import chat.simplex.common.views.chat.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.datetime.Clock
import kotlin.test.Test
import kotlin.test.assertEquals
class ChatItemsMergerTest {
@Test
fun testRecalculateSplitPositions() {
val oldItems = listOf(ChatItem.getSampleData(0), ChatItem.getSampleData(123L), ChatItem.getSampleData(124L), ChatItem.getSampleData(125L))
val splits1 = MutableStateFlow(listOf(123L))
val chatState1 = ActiveChatState(splits = splits1)
val removed1 = listOf(oldItems[1])
val newItems1 = oldItems - removed1
val recalc1 = recalculateChatStatePositions(chatState1)
recalc1.removed(removed1.map { Triple(it.id, oldItems.indexOf(removed1[0]), it.isRcvNew) }, newItems1)
assertEquals(1, splits1.value.size)
assertEquals(124L, splits1.value.first())
val splits2 = MutableStateFlow(listOf(123L))
val chatState2 = ActiveChatState(splits = splits2)
val removed2 = listOf(oldItems[1], oldItems[2])
val newItems2 = oldItems - removed2
val recalc2 = recalculateChatStatePositions(chatState2)
recalc2.removed(removed2.mapIndexed { index, it -> Triple(it.id, oldItems.indexOf(removed2[index]), it.isRcvNew) }, newItems2)
assertEquals(1, splits2.value.size)
assertEquals(125L, splits2.value.first())
val splits3 = MutableStateFlow(listOf(123L))
val chatState3 = ActiveChatState(splits = splits3)
val removed3 = listOf(oldItems[1], oldItems[2], oldItems[3])
val newItems3 = oldItems - removed3
val recalc3 = recalculateChatStatePositions(chatState3)
recalc3.removed(removed3.mapIndexed { index, it -> Triple(it.id, oldItems.indexOf(removed3[index]), it.isRcvNew) }, newItems3)
assertEquals(0, splits3.value.size)
val splits4 = MutableStateFlow(listOf(123L))
val chatState4 = ActiveChatState(splits = splits4)
val recalc4 = recalculateChatStatePositions(chatState4)
recalc4.cleared()
assertEquals(0, splits4.value.size)
}
@Test
fun testItemsMerging() {
val items = listOf(
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(100L, Clock.System.now(), text = ""), CIContent.SndGroupFeature(GroupFeature.Voice, GroupPreference(GroupFeatureEnabled.ON), memberRole_ = null), reactions = emptyList()),
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(99L, Clock.System.now(), text = ""), CIContent.SndGroupFeature(GroupFeature.FullDelete, GroupPreference(GroupFeatureEnabled.ON), memberRole_ = null), reactions = emptyList()),
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(98L, Clock.System.now(), text = "", itemDeleted = CIDeleted.Deleted(null)), CIContent.RcvDeleted(CIDeleteMode.cidmBroadcast), reactions = emptyList()),
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(97L, Clock.System.now(), text = "", itemDeleted = CIDeleted.Deleted(null)), CIContent.RcvDeleted(CIDeleteMode.cidmBroadcast), reactions = emptyList()),
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(96L, Clock.System.now(), text = ""), CIContent.RcvMsgContent(MsgContent.MCText("")), reactions = emptyList()),
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(95L, Clock.System.now(), text = ""), CIContent.RcvMsgContent(MsgContent.MCText("")), reactions = emptyList()),
ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(94L, Clock.System.now(), text = ""), CIContent.RcvMsgContent(MsgContent.MCText("")), reactions = emptyList()),
)
val unreadCount = mutableStateOf(0)
val chatState = ActiveChatState()
val merged1 = MergedItems.create(items, unreadCount, emptySet(), chatState)
assertEquals(
listOf(
listOf(0, false,
listOf(
listOf(0, 100, CIMergeCategory.ChatFeature),
listOf(1, 99, CIMergeCategory.ChatFeature)
)
),
listOf(2, false,
listOf(
listOf(0, 98, CIMergeCategory.RcvItemDeleted),
listOf(1, 97, CIMergeCategory.RcvItemDeleted)
)
),
listOf(4, true,
listOf(
listOf(0, 96, null),
)
),
listOf(5, true,
listOf(
listOf(0, 95, null),
)
),
listOf(6, true,
listOf(
listOf(0, 94, null)
)
)
).toList().toString(),
merged1.items.map {
listOf(
it.startIndexInReversedItems,
if (it is MergedItem.Grouped) it.revealed else true,
when (it) {
is MergedItem.Grouped -> it.items.mapIndexed { index, listItem ->
listOf(index, listItem.item.id, listItem.item.mergeCategory)
}
is MergedItem.Single -> listOf(listOf(0, it.item.item.id, it.item.item.mergeCategory))
}
)
}.toString()
)
val merged2 = MergedItems.create(items, unreadCount, setOf(98L, 97L), chatState)
assertEquals(
listOf(
listOf(0, false,
listOf(
listOf(0, 100, CIMergeCategory.ChatFeature),
listOf(1, 99, CIMergeCategory.ChatFeature)
)
),
listOf(2, true,
listOf(
listOf(0, 98, CIMergeCategory.RcvItemDeleted),
)
),
listOf(3, true,
listOf(
listOf(0, 97, CIMergeCategory.RcvItemDeleted)
)
),
listOf(4, true,
listOf(
listOf(0, 96, null),
)
),
listOf(5, true,
listOf(
listOf(0, 95, null),
)
),
listOf(6, true,
listOf(
listOf(0, 94, null)
)
)
).toList().toString(),
merged2.items.map {
listOf(
it.startIndexInReversedItems,
if (it is MergedItem.Grouped) it.revealed else true,
when (it) {
is MergedItem.Grouped -> it.items.mapIndexed { index, listItem ->
listOf(index, listItem.item.id, listItem.item.mergeCategory)
}
is MergedItem.Single -> listOf(listOf(0, it.item.item.id, it.item.item.mergeCategory))
}
)
}.toString()
)
}
}
@@ -56,7 +56,7 @@ fun showApp() {
} else {
// The last possible cause that can be closed
chatModel.chatId.value = null
chatModel.chatItems.clear()
chatModel.chatItems.clearAndNotify()
}
chatModel.activeCall.value?.let {
withBGApi {
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Dp
import chat.simplex.common.platform.*
import chat.simplex.common.simplexWindowState
@@ -37,3 +38,6 @@ actual fun LocalWindowWidth(): Dp = with(LocalDensity.current) {
simplexWindowState.windowState.size.width
}
}
@Composable
actual fun LocalWindowHeight(): Dp = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
@@ -45,7 +45,7 @@ private fun ActiveCallInteractiveAreaOneHand(call: Call, showMenu: MutableState<
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
openChat(chat.remoteHostId, chat.chatInfo)
}
}
},
@@ -110,7 +110,7 @@ private fun ActiveCallInteractiveAreaNonOneHand(call: Call, showMenu: MutableSta
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
openChat(chat.remoteHostId, chat.chatInfo)
}
}
},
@@ -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)
}
+4 -4
View File
@@ -24,11 +24,11 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.1.1
android.version_code=249
android.version_name=6.2-beta.0
android.version_code=251
desktop.version_name=6.1.1
desktop.version_code=74
desktop.version_name=6.2-beta.0
desktop.version_code=75
kotlin.version=1.9.23
gradle.plugin.version=8.2.0

Some files were not shown because too many files have changed in this diff Show More