From 396fa7f988bb12460dfe3f9217b8f0886d601029 Mon Sep 17 00:00:00 2001 From: Diogo Date: Fri, 22 Nov 2024 14:42:07 +0000 Subject: [PATCH] desktop, android: server operators (#5212) * api and types * whats new view * new package and movements * move network and servers to new package * network and servers view * wip * api update * build * conditions modal in settings * network and servers fns * save server fixes * more servers * move protocol servers view * message servers with validation * added message servers * use for files * fix error by server type * list xftp servers * android: add server view (#5221) * android add server wip * test servers button * fix save of custom servers * remove unused code * edit and view servers * fix * allow to enable untested * show all test errors in the end * android: custom servers view (#5224) * cleanup * validation footers * operator enabled validation * var -> val * reuse onboarding button * AppBarTitle without alpha * remove non scrollable title * change in AppBarTitle * changes in AppBar * bold strings + bordered text view * ChooseServerOperators * fix * new server view wip * fix * scan * rename * fix roles toggle texts * UsageConditionsView * aligned texts * more texts * replace hard coded logos with object ref * use snapshot state to recalculate errors * align views; fix accept * remove extra snapshots * fix ts * fix whatsnew * stage * animation on onboarding * refactor and fix * remember * fix start chat alert * show notice in chat list * refactor * fix validation * open conditions * whats new view updates * icon for navigation improvements * remove debug * simplify * fix * handle click when have unsaved changes * fix * Revert "fix" This reverts commit d49c3736415a9fe08464237e041c2d2b6fc665d3. * Revert "handle click when have unsaved changes" This reverts commit 39ca03f9c086b87b5b6571f93443ba16f2870d24. * fixed close of modals in whats new view * grouping * android: conditions view paddings (#5228) * revert padding * refresh operators on save * fixed modals in different views for desktop * ios: fix enabling operator model update * fix modals --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../NetworkAndServers/NetworkAndServers.swift | 2 + .../ScanProtocolServer.android.kt | 6 +- .../kotlin/chat/simplex/common/App.kt | 9 +- .../chat/simplex/common/model/ChatModel.kt | 9 + .../chat/simplex/common/model/SimpleXAPI.kt | 557 ++++++++++++-- .../chat/simplex/common/platform/Core.kt | 12 +- .../chat/simplex/common/views/WelcomeView.kt | 4 +- .../simplex/common/views/chat/ChatView.kt | 2 +- .../common/views/chatlist/ChatListView.kt | 26 +- .../views/chatlist/ServersSummaryView.kt | 19 +- .../common/views/helpers/AppBarTitle.kt | 19 +- .../common/views/helpers/CollapsingAppBar.kt | 1 + .../common/views/helpers/DefaultTopAppBar.kt | 3 +- .../common/views/migration/MigrateToDevice.kt | 1 + .../views/onboarding/ChooseServerOperators.kt | 354 +++++++++ .../common/views/onboarding/HowItWorks.kt | 4 +- .../common/views/onboarding/OnboardingView.kt | 1 + .../views/onboarding/SetNotificationsMode.kt | 8 +- .../onboarding/SetupDatabasePassphrase.kt | 3 +- .../common/views/onboarding/SimpleXInfo.kt | 5 +- .../common/views/onboarding/WhatsNewView.kt | 261 ++++--- .../common/views/remote/ConnectMobileView.kt | 1 + .../views/usersettings/ProtocolServersView.kt | 383 ---------- .../common/views/usersettings/SettingsView.kt | 7 +- .../AdvancedNetworkSettings.kt | 3 +- .../NetworkAndServers.kt | 540 ++++++++++++-- .../networkAndServers/NewServerView.kt | 144 ++++ .../networkAndServers/OperatorView.kt | 701 ++++++++++++++++++ .../ProtocolServerView.kt | 169 +++-- .../networkAndServers/ProtocolServersView.kt | 407 ++++++++++ .../ScanProtocolServer.kt | 14 +- .../commonMain/resources/MR/base/strings.xml | 84 +++ .../resources/MR/images/flux_logo@4x.png | Bin 0 -> 34876 bytes .../MR/images/flux_logo_light@4x.png | Bin 0 -> 33847 bytes .../MR/images/flux_logo_symbol@4x.png | Bin 0 -> 17248 bytes .../resources/MR/images/ic_outbound.svg | 1 + .../ScanProtocolServer.desktop.kt | 9 - .../ScanProtocolServer.desktop.kt | 9 + 38 files changed, 3032 insertions(+), 746 deletions(-) rename apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/{ => networkAndServers}/ScanProtocolServer.android.kt (69%) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt delete mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/{ => networkAndServers}/AdvancedNetworkSettings.kt (99%) rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/{ => networkAndServers}/NetworkAndServers.kt (52%) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/{ => networkAndServers}/ProtocolServerView.kt (51%) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/{ => networkAndServers}/ScanProtocolServer.kt (62%) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg delete mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 9b03b79353..8b6421b502 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -395,7 +395,9 @@ func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServer // 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 } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.android.kt similarity index 69% rename from apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.android.kt index af5a27be11..8b5def7451 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.android.kt @@ -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() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 7af1d574ad..b1ce003812 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.draw.* import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp @@ -42,7 +41,6 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlin.math.absoluteValue @Composable fun AppScreen() { @@ -194,6 +192,13 @@ fun MainScreen() { OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} OnboardingStage.LinkAMobile -> LinkAMobile() OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) + OnboardingStage.Step3_ChooseServerOperators -> { + val modalData = remember { ModalData() } + modalData.ChooseServerOperators(true) + if (appPlatform.isDesktop) { + ModalManager.fullscreen.showInView() + } + } // Ensure backwards compatibility with old onboarding stage for address creation, otherwise notification setup would be skipped OnboardingStage.Step3_CreateSimpleXAddress -> SetNotificationsMode(chatModel) OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ef777f151f..e501ed5a91 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -145,6 +145,8 @@ object ChatModel { val clipboardHasText = mutableStateOf(false) val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true)) + val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty) + val updatingProgress = mutableStateOf(null as Float?) var updatingRequest: Closeable? = null @@ -2567,6 +2569,13 @@ fun localTimestamp(t: Instant): String { return ts.toJavaLocalDateTime().format(dateFormatter) } +fun localDate(t: Instant): String { + val tz = TimeZone.currentSystemDefault() + val ts: LocalDateTime = t.toLocalDateTime(tz) + val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + return ts.toJavaLocalDateTime().format(dateFormatter) +} + @Serializable sealed class CIStatus { @Serializable @SerialName("sndNew") class SndNew: CIStatus() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 580a663945..0cab7ce8e9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -26,10 +26,12 @@ import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings +import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel @@ -963,36 +965,6 @@ object ChatController { return null } - suspend fun getUserProtoServers(rh: Long?, serverProtocol: ServerProtocol): UserProtocolServers? { - val userId = kotlin.runCatching { currentUserId("getUserProtoServers") }.getOrElse { return null } - val r = sendCmd(rh, CC.APIGetUserProtoServers(userId, serverProtocol)) - return if (r is CR.UserProtoServers) { if (rh == null) r.servers else r.servers.copy(protoServers = r.servers.protoServers.map { it.copy(remoteHostId = rh) }) } - else { - Log.e(TAG, "getUserProtoServers bad response: ${r.responseType} ${r.details}") - AlertManager.shared.showAlertMsg( - generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_loading_smp_servers else MR.strings.error_loading_xftp_servers), - "${r.responseType}: ${r.details}" - ) - null - } - } - - suspend fun setUserProtoServers(rh: Long?, serverProtocol: ServerProtocol, servers: List): Boolean { - val userId = kotlin.runCatching { currentUserId("setUserProtoServers") }.getOrElse { return false } - val r = sendCmd(rh, CC.APISetUserProtoServers(userId, serverProtocol, servers)) - return when (r) { - is CR.CmdOk -> true - else -> { - Log.e(TAG, "setUserProtoServers bad response: ${r.responseType} ${r.details}") - AlertManager.shared.showAlertMsg( - generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_saving_smp_servers else MR.strings.error_saving_xftp_servers), - generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.ensure_smp_server_address_are_correct_format_and_unique else MR.strings.ensure_xftp_server_address_are_correct_format_and_unique) - ) - false - } - } - } - suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? { val userId = currentUserId("testProtoServer") val r = sendCmd(rh, CC.APITestProtoServer(userId, server)) @@ -1005,6 +977,106 @@ object ChatController { } } + suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? { + val r = sendCmd(rh, CC.ApiGetServerOperators()) + + return when (r) { + is CR.ServerOperatorConditions -> r.conditions + else -> { + Log.e(TAG, "getServerOperators bad response: ${r.responseType} ${r.details}") + null + } + } + } + + suspend fun setServerOperators(rh: Long?, operators: List): 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? { + 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): 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): List? { + 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? { + 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): ServerOperatorConditionsDetail? { + val r = sendCmd(rh, CC.ApiAcceptConditions(conditionsId, operatorIds)) + return when (r) { + is CR.ServerOperatorConditions -> r.conditions + else -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.error_accepting_operator_conditions), + "${r.responseType}: ${r.details}" + ) + Log.e(TAG, "acceptConditions bad response: ${r.responseType} ${r.details}") + null + } + } + } + suspend fun getChatItemTTL(rh: Long?): ChatItemTTL { val userId = currentUserId("getChatItemTTL") val r = sendCmd(rh, CC.APIGetChatItemTTL(userId)) @@ -3037,9 +3109,15 @@ sealed class CC { class APIGetGroupLink(val groupId: Long): CC() class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC() class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC() - class APIGetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol): CC() - class APISetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol, val servers: List): CC() class APITestProtoServer(val userId: Long, val server: String): CC() + class ApiGetServerOperators(): CC() + class ApiSetServerOperators(val operators: List): CC() + class ApiGetUserServers(val userId: Long): CC() + class ApiSetUserServers(val userId: Long, val userServers: List): CC() + class ApiValidateServers(val userId: Long, val userServers: List): CC() + class ApiGetUsageConditions(): CC() + class ApiSetConditionsNotified(val conditionsId: Long): CC() + class ApiAcceptConditions(val conditionsId: Long, val operatorIds: List): CC() class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC() class APIGetChatItemTTL(val userId: Long): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() @@ -3197,9 +3275,15 @@ sealed class CC { is APIGetGroupLink -> "/_get link #$groupId" is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId" is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" - is APIGetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()}" - is APISetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()} ${protoServersStr(servers)}" is APITestProtoServer -> "/_server test $userId $server" + is ApiGetServerOperators -> "/_operators" + is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}" + is ApiGetUserServers -> "/_servers $userId" + is ApiSetUserServers -> "/_servers $userId ${json.encodeToString(userServers)}" + is ApiValidateServers -> "/_validate_servers $userId ${json.encodeToString(userServers)}" + is ApiGetUsageConditions -> "/_conditions" + is ApiSetConditionsNotified -> "/_conditions_notified ${conditionsId}" + is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}" is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" is APIGetChatItemTTL -> "/_ttl $userId" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" @@ -3342,9 +3426,15 @@ sealed class CC { is APIGetGroupLink -> "apiGetGroupLink" is APICreateMemberContact -> "apiCreateMemberContact" is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" - is APIGetUserProtoServers -> "apiGetUserProtoServers" - is APISetUserProtoServers -> "apiSetUserProtoServers" is APITestProtoServer -> "testProtoServer" + is ApiGetServerOperators -> "apiGetServerOperators" + is ApiSetServerOperators -> "apiSetServerOperators" + is ApiGetUserServers -> "apiGetUserServers" + is ApiSetUserServers -> "apiSetUserServers" + is ApiValidateServers -> "apiValidateServers" + is ApiGetUsageConditions -> "apiGetUsageConditions" + is ApiSetConditionsNotified -> "apiSetConditionsNotified" + is ApiAcceptConditions -> "apiAcceptConditions" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" is APISetNetworkConfig -> "apiSetNetworkConfig" @@ -3459,8 +3549,6 @@ sealed class CC { companion object { fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}" - - fun protoServersStr(servers: List) = json.encodeToString(ProtoServersConfig(servers)) } } @@ -3510,24 +3598,350 @@ enum class ServerProtocol { } @Serializable -data class ProtoServersConfig( - val servers: List +enum class OperatorTag { + @SerialName("simplex") SimpleX, + @SerialName("flux") Flux, + @SerialName("xyz") XYZ, + @SerialName("demo") Demo +} + +data class ServerOperatorInfo( + val description: List, + val website: String, + val logo: ImageResource, + val largeLogo: ImageResource, + val logoDarkMode: ImageResource, + val largeLogoDarkMode: ImageResource +) +val operatorsInfo: Map = 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, - val presetServers: List, +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, val deadline: Instant?, val showNotice: Boolean) : UsageConditionsAction() + @Serializable @SerialName("accepted") data class Accepted(val operators: List) : UsageConditionsAction() + + val shouldShowNotice: Boolean + get() = when (this) { + is Review -> showNotice + else -> false + } +} + +@Serializable +data class ServerOperatorConditionsDetail( + val serverOperators: List, + 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, + 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, + val xftpServers: List +) { + val id: String + get() = operator?.operatorId?.toString() ?: "nil operator" + + val operator_: ServerOperator + get() = operator ?: ServerOperator( + operatorId = 0, + operatorTag = null, + tradeName = "", + legalName = null, + serverDomains = emptyList(), + conditionsAcceptance = ConditionsAcceptance.Accepted(null), + enabled = false, + smpRoles = ServerRoles(storage = true, proxy = true), + xftpRoles = ServerRoles(storage = true, proxy = true) + ) + + companion object { + val sampleData1 = UserOperatorServers( + operator = ServerOperator.sampleData1, + smpServers = listOf(UserServer.sampleData.preset), + xftpServers = listOf(UserServer.sampleData.xftpPreset) + ) + + val sampleDataNilOperator = UserOperatorServers( + operator = null, + smpServers = listOf(UserServer.sampleData.preset), + xftpServers = listOf(UserServer.sampleData.xftpPreset) + ) + } +} + +@Serializable +sealed class UserServersError { + @Serializable @SerialName("noServers") data class NoServers(val protocol: ServerProtocol, val user: UserRef?): UserServersError() + @Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() + @Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() + @Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError() + + val globalError: String? + get() = when (this.protocol_) { + ServerProtocol.SMP -> globalSMPError + ServerProtocol.XFTP -> globalXFTPError + } + + private val protocol_: ServerProtocol + get() = when (this) { + is NoServers -> this.protocol + is StorageMissing -> this.protocol + is ProxyMissing -> this.protocol + is DuplicateServer -> this.protocol + } + + val globalSMPError: String? + get() = if (this.protocol_ == ServerProtocol.SMP) { + when (this) { + is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured)}" } + ?: generalGetString(MR.strings.no_message_servers_configured) + + is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_receiving)}" } + ?: generalGetString(MR.strings.no_message_servers_configured_for_receiving) + + is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_private_routing)}" } + ?: generalGetString(MR.strings.no_message_servers_configured_for_private_routing) + + else -> null + } + } else { + null + } + + val globalXFTPError: String? + get() = if (this.protocol_ == ServerProtocol.XFTP) { + when (this) { + is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured)}" } + ?: generalGetString(MR.strings.no_media_servers_configured) + + is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_sending)}" } + ?: generalGetString(MR.strings.no_media_servers_configured_for_sending) + + is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_private_routing)}" } + ?: generalGetString(MR.strings.no_media_servers_configured_for_private_routing) + + else -> null + } + } else { + null + } + + private fun userStr(user: UserRef): String { + return String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + } +} + +@Serializable +data class UserServer( val remoteHostId: Long?, + val serverId: Long?, val server: String, val preset: Boolean, val tested: Boolean? = null, - val enabled: Boolean + val enabled: Boolean, + val deleted: Boolean ) { @Transient private val createdAt: Date = Date() @@ -3541,35 +3955,51 @@ data class ServerCfg( get() = server.isBlank() companion object { - val empty = ServerCfg(remoteHostId = null, server = "", preset = false, tested = null, enabled = false) + val empty = UserServer(remoteHostId = null, serverId = null, server = "", preset = false, tested = null, enabled = false, deleted = false) class SampleData( - val preset: ServerCfg, - val custom: ServerCfg, - val untested: ServerCfg + val preset: UserServer, + val custom: UserServer, + val untested: UserServer, + val xftpPreset: UserServer ) val sampleData = SampleData( - preset = ServerCfg( + preset = UserServer( remoteHostId = null, + serverId = 1, server = "smp://abcd@smp8.simplex.im", preset = true, tested = true, - enabled = true + enabled = true, + deleted = false ), - custom = ServerCfg( + custom = UserServer( remoteHostId = null, + serverId = 2, server = "smp://abcd@smp9.simplex.im", preset = false, tested = false, - enabled = false + enabled = false, + deleted = false ), - untested = ServerCfg( + untested = UserServer( remoteHostId = null, + serverId = 3, server = "smp://abcd@smp10.simplex.im", preset = false, tested = null, - enabled = true + enabled = true, + deleted = false + ), + xftpPreset = UserServer( + remoteHostId = null, + serverId = 4, + server = "xftp://abcd@xftp8.simplex.im", + preset = true, + tested = true, + enabled = true, + deleted = false ) ) } @@ -4928,8 +5358,11 @@ sealed class CR { @Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List): 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): CR() + @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List): CR() + @Serializable @SerialName("usageConditions") class UsageConditions(val usageConditions: UsageConditionsDetail, val conditionsText: String?, val acceptedConditions: UsageConditionsDetail?): CR() @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() @Serializable @SerialName("contactInfo") class ContactInfo(val user: UserRef, val contact: Contact, val connectionStats_: ConnectionStats? = null, val customUserProfile: Profile? = null): CR() @@ -5108,8 +5541,11 @@ sealed class CR { is ApiChats -> "apiChats" is ApiChat -> "apiChat" is ApiChatItemInfo -> "chatItemInfo" - is UserProtoServers -> "userProtoServers" is ServerTestResult -> "serverTestResult" + is ServerOperatorConditions -> "serverOperatorConditions" + is UserServers -> "userServers" + is UserServersValidation -> "userServersValidation" + is UsageConditions -> "usageConditions" is ChatItemTTL -> "chatItemTTL" is NetworkConfig -> "networkConfig" is ContactInfo -> "contactInfo" @@ -5278,8 +5714,11 @@ sealed class CR { is ApiChats -> withUser(user, json.encodeToString(chats)) is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") - is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") + is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}" + is UserServers -> withUser(user, "userServers: ${json.encodeToString(userServers)}") + is UserServersValidation -> withUser(user, "serverErrors: ${json.encodeToString(serverErrors)}") + is UsageConditions -> "usageConditions: ${json.encodeToString(usageConditions)}\nnacceptedConditions: ${json.encodeToString(acceptedConditions)}" is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL)) is NetworkConfig -> json.encodeToString(networkConfig) is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 79132b5eb1..08ca72c6bd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -118,6 +118,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) val user = chatController.apiGetActiveUser(null) chatModel.currentUser.value = user + chatModel.conditions.value = chatController.getServerOperators(null) ?: ServerOperatorConditionsDetail.empty if (user == null) { chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = null @@ -137,13 +138,12 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } } else if (startChat().await()) { val savedOnboardingStage = appPreferences.onboardingStage.get() - val next = if (appPlatform.isAndroid) { - OnboardingStage.Step4_SetNotificationsMode - } else { - OnboardingStage.OnboardingComplete - } val newStage = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { - next + if (appPlatform.isAndroid) { + OnboardingStage.Step4_SetNotificationsMode + } else { + OnboardingStage.OnboardingComplete + } } else { savedOnboardingStage } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 17658d23e8..15d38c5490 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -165,7 +165,7 @@ fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) { if (!chatModel.connectedToRemote()) { chatModel.localUserCreated.value = true } - controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) + controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators) controller.startChat(user) controller.switchUIRemoteHost(null) close() @@ -204,7 +204,7 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) { OnboardingStage.Step2_5_SetupDatabasePassphrase } else { - OnboardingStage.Step4_SetNotificationsMode + OnboardingStage.Step3_ChooseServerOperators }) } else { // the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 8dd3e42440..e9e590ee95 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1249,7 +1249,7 @@ fun BoxScope.ChatItemsList( } else { null } - val showAvatar = if (merged is MergedItem.Grouped) shouldShowAvatar(item, listItem.nextItem) else true + val showAvatar = shouldShowAvatar(item, listItem.nextItem) val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } val itemSeparation: ItemSeparation val prevItemSeparationLargeGap: Boolean diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 9661a305cc..20bb65ec7d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -25,19 +25,21 @@ import androidx.compose.ui.unit.* import chat.simplex.common.AppLock import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatController.setConditionsNotified import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.WhatsNewView -import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call import chat.simplex.common.views.chat.item.CIFileViewScope import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.mkValidName import chat.simplex.common.views.newchat.* +import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.showInvalidNameAlert import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton +import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow @@ -115,10 +117,26 @@ fun ToggleChatListCard() { @Composable fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, 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) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index 4e3ee2340c..acbc72ff48 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -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() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt index 195ec020e5..afb557cc78 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt @@ -17,11 +17,21 @@ import dev.icerock.moko.resources.compose.painterResource import kotlin.math.absoluteValue @Composable -fun AppBarTitle(title: String, hostDevice: Pair? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) { +fun AppBarTitle( + title: String, + hostDevice: Pair? = 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? = 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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt index 50942169b3..ad6611b9d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt @@ -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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index cf0c5f7e96..4bf20d2128 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 90f8593c4a..28ec77de70 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt new file mode 100644 index 0000000000..8b383e0146 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -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>) { + 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>, + selectedOperatorIds: State>, + 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>, selectedOperatorIds: State>, 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>, + selectedOperatorIds: State>, + 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>, + selectedOperatorIds: State>, + 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, selectedOperatorIds: Set): List? { + 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() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index 98e8ec971d..34b6209ffe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -48,8 +48,8 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference? } @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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index d4c63248e5..510df13c3d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -5,6 +5,7 @@ enum class OnboardingStage { Step2_CreateProfile, LinkAMobile, Step2_5_SetupDatabasePassphrase, + Step3_ChooseServerOperators, Step3_CreateSimpleXAddress, Step4_SetNotificationsMode, OnboardingComplete diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt index d6d5753b6c..49c91813dc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt @@ -16,8 +16,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatModel import chat.simplex.common.model.NotificationsMode -import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.changeNotificationsMode @@ -26,7 +25,7 @@ import chat.simplex.res.MR @Composable fun SetNotificationsMode(m: ChatModel) { LaunchedEffect(Unit) { - prepareChatBeforeNotificationsSetup(m) + prepareChatBeforeFinishingOnboarding() } CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { @@ -57,6 +56,7 @@ fun SetNotificationsMode(m: ChatModel) { onboarding = OnboardingStage.OnboardingComplete, onclick = { changeNotificationsMode(currentMode.value, m) + ModalManager.fullscreen.closeModals() } ) // Reserve space @@ -99,7 +99,7 @@ fun SelectableCard(currentValue: State, newValue: T, title: String, descr Spacer(Modifier.height(14.dp)) } -private fun prepareChatBeforeNotificationsSetup(chatModel: ChatModel) { +fun prepareChatBeforeFinishingOnboarding() { // No visible users but may have hidden. In this case chat should be started anyway because it's stopped on this stage with hidden users if (chatModel.users.any { u -> !u.user.hidden }) return withBGApi { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 4ad2675e83..f20cb38dad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -17,7 +17,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.database.* @@ -36,7 +35,7 @@ fun SetupDatabasePassphrase(m: ChatModel) { val confirmNewKey = rememberSaveable { mutableStateOf("") } fun nextStep() { if (appPlatform.isAndroid || chatModel.currentUser.value != null) { - m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) + m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators) } else { m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index e43404cb07..b133ae27d4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -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 ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index bdbef3b654..6cf945bcba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -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>) { @@ -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> = 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> = 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, + val features: List, val post: String? = null, ) @@ -186,18 +231,18 @@ private val versionDescriptions: List = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = {} ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index e727b94781..1d01ab11ff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt deleted file mode 100644 index f5e3cda2c7..0000000000 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt +++ /dev/null @@ -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()) } - var servers by remember { stateGetOrPut("servers") { emptyList() } } - 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, - 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, 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): Boolean = servers.all { srv -> - address.hostnames.all { host -> - srv.id == s.id || !srv.server.contains(host) - } -} - -private fun hasAllPresets(presetServers: List, servers: List, m: ChatModel): Boolean = - presetServers.all { hasPreset(it, servers) } ?: true - -private fun addAllPresets(rhId: Long?, presetServers: List, servers: List, m: ChatModel): List { - val toAdd = ArrayList() - for (srv in presetServers) { - if (!hasPreset(srv, servers)) { - toAdd.add(srv) - } - } - return toAdd -} - -private fun hasPreset(srv: ServerCfg, servers: List): Boolean = - servers.any { it.server == srv.server } - -private suspend fun testServers(testing: MutableState, servers: List, m: ChatModel, onUpdated: (List) -> 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): List { - 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, m: ChatModel, onUpdated: (List) -> Unit): Map { - val fs: MutableMap = mutableMapOf() - val updatedServers = ArrayList(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>, servers: List, 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, - ) -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 78c5e3b212..f3d22e0cdf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt similarity index 99% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index 5757b5d1f4..838cac0172 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt similarity index 52% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 2c4870b121..ef5b82a5d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -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 = remember { mutableStateOf(netCfg.useSocksProxy) } + val currUserServers = remember { stateGetOrPut("currUserServers") { emptyList() } } + val userServers = remember { stateGetOrPut("userServers") { emptyList() } } + val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList() } } + 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, onionHosts: MutableState, + currUserServers: MutableState>, + serverErrors: MutableState>, + userServers: MutableState>, 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>, + userServers: MutableState>, + serverErrors: MutableState>, + 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>, + userServers: MutableState>, + close: () -> Unit, + rhId: Long? +) { + suspend fun acceptForOperators(rhId: Long?, operatorIds: List, 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, 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>, updatedOperators: List) { + 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, + serverErrors: MutableState> +) { + try { + val errors = chatController.validateServers(rhId, userServersToValidate) ?: return + serverErrors.value = errors + } catch (ex: Exception) { + Log.e(TAG, ex.stackTraceToString()) + } +} + +fun serversCanBeSaved( + currUserServers: List, + userServers: List, + serverErrors: List +): Boolean { + return userServers != currUserServers && serverErrors.isEmpty() +} + +fun globalServersError(serverErrors: List): String? { + for (err in serverErrors) { + if (err.globalError != null) { + return err.globalError + } + } + return null +} + +fun globalSMPServersError(serverErrors: List): String? { + for (err in serverErrors) { + if (err.globalSMPError != null) { + return err.globalSMPError + } + } + return null +} + +fun globalXFTPServersError(serverErrors: List): String? { + for (err in serverErrors) { + if (err.globalXFTPError != null) { + return err.globalXFTPError + } + } + return null +} + +fun findDuplicateHosts(serverErrors: List): Set { + 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>, + userServers: MutableState> +) { + 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()) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt new file mode 100644 index 0000000000..1ec2534ab1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -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>, + serverErrors: MutableState>, + 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, + 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 +): Pair? { + 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>, + serverErrors: MutableState>, + 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) + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt new file mode 100644 index 0000000000..cb02745511 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -0,0 +1,701 @@ +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>, + userServers: MutableState>, + serverErrors: MutableState>, + 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>, + serverErrors: MutableState>, + 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>, + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, + currentUser: User?, + rhId: Long?, + testing: MutableState +) { + 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) } }) { + Image(painterResource(operator.largeLogo), null, Modifier.height(48.dp)) + } + UseOperatorToggle( + scope = scope, + 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( + scope: CoroutineScope, + currUserServers: MutableState>, + userServers: MutableState>, + serverErrors: MutableState>, + 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>, + userServers: MutableState>, + serverErrors: MutableState>, + 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, 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(end = DEFAULT_PADDING, start = DEFAULT_PADDING, 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(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING)) { + ConditionsTextView(rhId) + } + AcceptConditionsButton(close) + } + } +} + +@Composable +fun ConditionsTextView( + rhId: Long? +) { + val conditionsData = remember { mutableStateOf?>(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, 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>, operatorIndex: Int, enabled: Boolean) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + operator = this[operatorIndex].operator?.copy(enabled = enabled) + ) + } +} \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt similarity index 51% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index be566e6c5a..bebc96a28c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -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>, + serverErrors: MutableState>, + 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, 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, 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, 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, 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 = +suspend fun testServerConnection(server: UserServer, m: ChatModel): Pair = try { val r = m.controller.testProtoServer(server.remoteHostId, server.server) server.copy(tested = r == null) to r diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt new file mode 100644 index 0000000000..63bf8b1dc4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -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>, + serverErrors: MutableState>, + 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>, + serverErrors: MutableState>, + operatorIndex: Int, + navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, + currentUser: User?, + rhId: Long?, + testing: MutableState +) { + 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, + xftpServers: List, + testing: MutableState, + onUpdate: (ServerProtocol, List) -> 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>, + serverErrors: MutableState>, + 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) { + 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, + smpServers: List, + xftpServers: List, + m: ChatModel, + onUpdate: (ServerProtocol, List) -> 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): List { + 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, m: ChatModel, onUpdated: (List) -> Unit): Map { + val fs: MutableMap = mutableMapOf() + val updatedServers = ArrayList(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>, + 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>, + 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) + } + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt similarity index 62% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt index 966f44cac7..56f16d4eb1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt @@ -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), diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 7236b22563..3c1ede1d23 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -109,6 +109,16 @@ Invalid display name! This display name is invalid. Please choose another name. Error switching profile! + Error saving servers + No message servers. + No servers to receive messages. + No servers for private message routing. + No media & file servers. + No servers to send files. + No servers to receive files. + For chat profile %s: + Errors in servers configuration. + Error accepting conditions Connection timeout @@ -750,6 +760,7 @@ Some servers failed the test: Scan server QR code Enter server manually + New server Preset server Your server Your server address @@ -1038,6 +1049,19 @@ Random passphrase is stored in settings as plaintext.\nYou can change it later. Use random passphrase + + Choose operators + Network operators + When more than one network operator is enabled, the app will use the servers of different operators for each conversation. + For example, if you receive messages via SimpleX Chat server, the app will use one of Flux servers for private routing. + Select network operators to use. + You can configure servers via settings. + Conditions will be accepted for enabled operators after 30 days. + You can configure operators in Network & servers settings. + Review later + Update + Continue + Incoming video call Incoming audio call @@ -1667,6 +1691,59 @@ Save group profile Error saving group profile + + Preset servers + Review conditions + Accepted conditions + Conditions will be automatically accepted for enabled operators on: %s. + Your servers + %s.]]> + %s.]]> + + + Operator + %s servers + Network operator + Website + Conditions accepted on: %s. + Conditions will be accepted on: %s. + Operator + Use servers + Use %s + Current conditions text couldn\'t be loaded, you can review conditions via this link: + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + View conditions + Accept conditions + Conditions of use + %s, accept conditions of use.]]> + Use for messages + To receive + For private routing + Added message servers + Use for files + To send + The servers for new files of your current chat profile + Added media & file servers + Open conditions + Open changes + + + Error updating server + Server protocol changed. + Server operator changed. + + + Operator server + Server added to operator %s. + Error adding server + TCP connection Reset to defaults @@ -2059,6 +2136,13 @@ Better message dates. Forward up to 20 messages at once. Delete or moderate up to 200 messages. + Network decentralization + The second preset operator in the app! + Enable flux + for better metadata privacy + Improved chat navigation + - Open chat on the first unread message.\n- Jump to quoted messages. + View updated conditions seconds diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png new file mode 100644 index 0000000000000000000000000000000000000000..87f1373d750bece6fbbd37fcdb4b115deaaeffbe GIT binary patch literal 34876 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

PL9g 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 F{C_Q?zn%a9 literal 0 HcmV?d00001 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png new file mode 100644 index 0000000000000000000000000000000000000000..e1d6dda4fedd57195f0ee425a0a28a579f16b266 GIT binary patch literal 33847 zcmafb1ymGj*Y3;^5=w_iI)t<$B140eAc%A$ih?-AAl(e8bcqPkQo_(8Eig19qM(2h zGIS^%(%g6S^Lz*Y|K58RYaN#|Z|r>b-p@OsH?FImB0EC{fj~~FT~*S7KnM{K2=oRC zG58yk=pJ_PKSJj#YI-E#KOd4iArJ@$L`_LS&%@$JDtU_j-I^o+4{5g`3dIj-HE!r8 zl5XLe8WZF5a_%Ma1|~9Ezxu49E2{f}kNZ1|*r(FM?qR!Z9R=F=UryJ$yV+~shSVz9z7ayH$e7iJ8P%H3JGeGwJe7?iuP6MJ!g);iU zq8oNWa|V}=s`S>6Fbhj#aiea0mZou?-udl;RrhV;_d9OnyIg~D$shlrx(FqekZn;- z!N$B@c3JFoZ9!?irG~Mb4dm2wLJ;;GEr-xE$kdwCzf&sJ(|?9IN+x>dF#oyq&Zdl2 zrRh^%-Lp^{j^jU_u@Ist7Bu%Kv%4O~(@jKhRo*EQZ5gw>RT(eB&cZ|XWp(E6L!ZH3 zaTORP!Jb{dd~3c>QMEpD{)wjbn6VgX4CL64Qn37#T%yVeSC@-I!Uaquw%c#=B6OSX zL8BtBZynlTF!6Am9DZ^)vAOhb+nQcEN7{3-gjWNO|8TIw=n!i!7~?A5mtT~eU*+p` zg0w53?rErt>O8(O#?T2?@_vq-~G7@^F zTzO&Nd7ImQxvp625|XHUr)gB!e)y!QwP z?8w;AFRVHKmfsi60GO(Vd-Do8KP6-A+b7L>P+9s<7df?`VT1{$&I)0(!k4vn5rL}kc^~qtV+K}r+8%pwIoJPdD4repu8N!HQo`?$a=!UIf z?PQYRzmIU0LmUX|!6bdsX5M;1tr&ghe86>m+#PDVN6$_YFmq;vnZvq+aW7Qn_doZY zc--hHc$zitxWUB8N05M1R6lQvLxz2+lm3O;73LKCE*ONLk;Dhh@Lcy!B}qH3e(`Tgke0c5mhaIWTU5( zggyR%`b*QX9P;Rv%*(7Ey_pAI;&kRG=c*tl%Xr0dZ&RXOF_Kvg_l|A~H_m<(+RARz z_#b9!1}@(`J;vhu^W%+rSl~ONIV%Nqjiv`MvQe1k!9{&u(L>*{PS7c*jR7tXjG;tRhe_MOHBH`+D-hjKn=4AQf)3!nOft2b^n!DF7AYVL$szyC9g)Bc zSMurG81UwgRpCaSOI-kuPdnV>wsmb=+? z7@8V~6!@QE0u8}0e)4%qmWN8mBLn;|8B_$l{YzpVT)-F!5Difi&@%}}S^*_RMq_ZE ztH(9?kpdWwgdJr=)Dr^1B$X$So2H?6h7&LSB}NDtOK~OirmEAUP?fT?*g=EapVS$y z99t^%tUNj^$lz#DIb3q_-rR=&4KtxD3cMn6OoA@r(EfBs#Se}pohJ*<(flPTn$qXM z9^7iz-De!{kY z7M|ly{!xY*+gaN!p9Up`s}EOydLNan}dpcJG1s9U;xPq=xN zF%{{Qn!Nrj!{xW0{pEebYc6fme|?Y(CjeySjS~b;Vql*r1m|wh0j3Z_3vEj_ZhG2A zKkwg`FF0^qDqK|dK({8lhGqtyp%8cZY~*Xxt{-X~B(Nb!c5zYuX$bw{k2)#jT`}kvLq`3)vk0sk!wKz|`z-!;$FWC!aJu@*%>O(gT8W&6`;&zXL;hz) z2*p}Fe`>7!E0RLR%ToE)Gjj<~^e-21ACKFmq4HOVDB>qxEie=9nupMR`Aju)1s2Pd zF+_;l6C3V33YM7IPon(GAV>+-IhI=1KY{;e^~bLcNXh|^+i@{>Lg#UFOiaya@l?Tyb+ zBHH^G)4jOc^rovWcGE&RCIUFAi0lCvoCG=8d3{Tcaz z3}tr3yYrPAM>FBZBi7U{IjbF+V24l?wMOb+yTM@8j0ipTt^$7o91FS8MIxXINwwP2&9e3Ko!q-gQHI1aUx;W-B@O^5|jK~`jySXH^&0YA6YODY-+7Xn1|1 z__-uW`rH^3^EYH`gzDh!rEAPDlI(=lie(F`?>q6xaK1e`p;L!xAi;T%wK&c<)9H4j6}^%)ib;YVP<~q5Dl3Z=gD*y9pBq9IJDbG7dGv_ zKlRL@<+TP7IQ^#bUcxU>n|u3Xi6_W*-J;fnV*sI`f}g;GrLMpJ?fDn3V)=o1-aL3F z-y>>t-rV#mM?8%N)8L5&O_mUgQF1dIqeg-7y>#Q~6KO>)J;V)F6KBj$o|K5@+||s+ zQXjDK3PaZf%g{Eo@di?=#IzkPvkPN5sj7P=Tbrl%#xo)kk2}Hw&F~8+YX; znF`~Jh2SX5F}Rs`4@Zi}9j&ofUYnct+f5~gx4uekHtb|`dXw_TyuZKD%Kbc+{Kxwz z&np0;KJioKAzliDf?xwgZ`dze#B!iN5=AKrdzITHJpNfg*!&%K>hci7pt;mb)at{- zYTT3`FaL)B-d+0b(Qszetkw5YTV$5dT~SJB!hwGA+nfBLcI^1LqjTLXaYB3rUNnK99+;`Dhfq^N8#d;@RX^yH-}rv_Fr#OHRdahf&UJf} zeM~tn%WEZ61nT=D7w!w^uyc7>tAXac^8Pl44l%Nbm(ITqe}gD$;#Jpor77#6&E5(k zLKTYdVasW9r@0k_8h6~=hElhCTaYWaH@z8REXi_7@+S5wo^QD59?lQ?g9Go|s7JT| zvW#cPEQ8|`+RDRo(}{0x9hQA^Ky~me4eI)RALN?6#_3_^9@3}_=?TcO4o%A9X8#4Z zN6(3^nBlnY$r!<&yywsT`fiZaZUPKRk&f<5RRi0M^yVD1ZIWrL*%z~ho69;O_aOu) z2UG^nQlqSqPa~J3OMJ`NQ=T(dN=Isj1*ds4y1zFMw%;^1?Yqt9M}1>7MTnQ*+{b@) z*sms=-5H2F(dycV2HFhlI0YZ@p_F^S{n9cU_>Uv_u8z(x1pd;0%;W%&) z_dWD+@u2W=<$f)R!zCkH^I&=0M*=CU-PdPNAG~!qtMrp$EmD6oQP!E8f9%WBd#K@8 zHF+l+O&;Hz)VCr9lz1z*_+PD@Di13Huu+RHk&ckOO^{fxYXG5_n*f$Rj(DPQau-fN z=df1NDJM7-$S(JaxIi2w8|<&AxRE{dIg$T#-3B@~{7QG=3n@_Z2AOhQ!P_B{;kZyT z*wwCe70f^pUoB-^SgFu~PjT(IQly>T79)$~u%X)KY|C zdpT<-ACnVL;-ITiA-YxhP%$K@-lwDJ8uk{ZhgVyS3vTRfMXki$ zB2FXW-59mgogKf%#!}W4M<9Gr9Tbh)Z(Nqnhk?bbIwajYz+|1BO$D8)9^&SlltBrIgBBDT=wzQh;#3?E1X1Q5`#=( zJ=oqC^JR&`DQw@L8AIIY5U_x5DnDu*DTO=Naj8d;v2b=_a&rkOnYz=9eR(cqxSul< zb){-&?|*0WVb$qpS6@-J?F|2$3O5^{urN<<%61l6nr^Lb(t)|LL*i=d*j$^P%q&O7b23?C07}79ff1!c6y84( zXPb^UV5x`4?;P^$&Qdc$$XS)A1wCg%LOZt5&bnmmM^n<6=fNehL29=ikb~ zSP%>B@1$k4ahw;eiu+XjEo87dH_?IjD60B9Zdh$xW!`a*EH*7s-!bA(L` zXFElL*d>0p{T8p0gFVR~U*3jGNfI)YLQg~ypL`REqC@*Q4gDeb0{Nh*}=9oqGMnpG~x0UiL@5-E1XCK z>NCkeN~#X7lZlD(p(_5!TgQ)lNXBwk|C2H*tI20xQuW^$DSc8sX-_%By3K7LHNgL7 zYk;EBtWT?fkj{PJPM_h-_s8Gs@!>s{)sXl)e&MO(3pbFm1en-Oc2fHcd8Hd1;I!O!M=k^^^7%8gO*r$S5VvTlzvQgHpEMm?_m& zyYEBBqPFP6^&Nn)V*9_g@LdDqesf*P;c}vYc^yZLI%#gi_n>z?6iOvaZ(7@lUdd}N zxZiae6aB8eYRBq!d8IyHe%xO$k-y<)vYHm9XfRhtE;e>*NwwGwgdY=?E5@%eB6yGrPF0vT_?lXf zXP>J8VXfQs@d?rcr>3_DH}n$y1xO`WV8%pj-cAI5yI&;jhK5dGry-5ReC!{tn0+B| zv1NfajN|nyKGeg`9>+9qaL&Fs;m@T&ILSL(WS`tMc@_q8Q* zYP#KJShnLuwbnI?6hwhI|M++onc_x8@D`*c()f;8`nil=!Aj(>7QH19xn<{wIEUD7 zO~;1!FOLrd4`8XKw-dQz^i#?aaeiOy=)A`82xBRj1f;<6V&mhc=jp2_EL`Gc9FTQt zCc$H{$1ZYBcXzVjPu#j95U*uyYMZKdTkA74^KVK$Wms58ckBJ8US4@Oc!|^s`Jh-H@ zrbX>x;#it?wQlKa5yVjAOh|du_i(zmDzyo@ap^~0*1w`?ReqC8p3;tE-{;k97daWZ zFmo|sIaq2MkH?!=@X_P`arvgnsxT%PzNg2z1bK3IxhklyOn+bxxYqcYeQ((?CU3~N z&>belOh^A=V7$R*;1?kBI9D^+wIfD9zbda@hPqp&REP-SK)r z7>RsI(LCEH^Mo`&fA4yR*Uqny`njRL#eFRFL0&azOf+2777M;1NEAhtA(|bJ$Ip)8 z7(Z4lWIZ-Q)#yB2PMnRK2dcNJ1WAunlW-3UdFl8{&-K!T|JorF^^U5Bm;LLL`mTqO z!|(3J;8MlIIw|EyzANL5gadCi&9X*^jMiqO=I04eOS6>nw-kD|C!uKl)0`P(-M+S* zert0kc0MJ&Mkz*82A}&2Pm7%CX2i�>Y9E5G~yEbTaSm_@QckJ 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 E0Eh^ \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt deleted file mode 100644 index 2d436dbbf0..0000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt +++ /dev/null @@ -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) -} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt new file mode 100644 index 0000000000..7d6f305a83 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt @@ -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) +}