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 0000000000..87f1373d75 Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png differ 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 0000000000..e1d6dda4fe Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png new file mode 100644 index 0000000000..0793b0ee85 Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg new file mode 100644 index 0000000000..762c5846c1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg @@ -0,0 +1 @@ + \ 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) +}