From d3b5bbe566ed2e7aa74e5906d9c587eb364945c5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 17 Jul 2023 12:24:52 +0100 Subject: [PATCH] android: delivery receipts (#2705) * android: delivery receipts * receipts toggle in privacy and in contact info (crashes), double tick in chat * double tick image * SetDeliveryReceiptsView * small changes * update users * remove import * fixing crash * prevent ConcurrentModificationException * fix enable all users * android enable all users --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 1 - .../SetDeliveryReceiptsView.swift | 19 ++- apps/ios/SimpleXChat/ChatTypes.swift | 8 +- .../java/chat/simplex/app/MainActivity.kt | 5 + .../main/java/chat/simplex/app/SimplexApp.kt | 4 + .../java/chat/simplex/app/model/ChatModel.kt | 20 ++- .../java/chat/simplex/app/model/SimpleXAPI.kt | 31 ++++- .../simplex/app/views/chat/ChatInfoView.kt | 64 ++++++++- .../simplex/app/views/chat/item/CIMetaView.kt | 9 +- .../app/views/usersettings/PrivacySettings.kt | 115 ++++++++++++++++ .../usersettings/SetDeliveryReceiptsView.kt | 123 ++++++++++++++++++ .../commonMain/resources/MR/base/strings.xml | 26 +++- .../resources/MR/images/ic_double_check.svg | 13 ++ 13 files changed, 420 insertions(+), 18 deletions(-) create mode 100644 apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SetDeliveryReceiptsView.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_double_check.svg diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 0a7aa00a5..d1da83748 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1133,7 +1133,6 @@ func startChat(refreshInvitations: Bool = true) throws { m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1 ? .step3_CreateSimpleXAddress : savedOnboardingStage - // TODO don't show on first start if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() { m.setDeliveryReceipts = true } diff --git a/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift b/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift index 3990d0178..4925b7af8 100644 --- a/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift @@ -25,10 +25,21 @@ struct SetDeliveryReceiptsView: View { Button("Enable") { Task { do { - try await apiSetAllContactReceipts(enable: true) - await MainActor.run { - m.setDeliveryReceipts = false - privacyDeliveryReceiptsSet.set(true) + if let currentUser = m.currentUser { + try await apiSetAllContactReceipts(enable: true) + await MainActor.run { + var updatedUser = currentUser + updatedUser.sendRcptsContacts = true + m.updateUser(updatedUser) + m.setDeliveryReceipts = false + privacyDeliveryReceiptsSet.set(true) + } + do { + let users = try await listUsersAsync() + await MainActor.run { m.users = users } + } catch let error { + logger.debug("listUsers error: \(responseError(error))") + } } } catch let error { AlertManager.shared.showAlert(Alert( diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a78ea558e..664c8277b 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -16,8 +16,6 @@ public struct User: Decodable, NamedChat, Identifiable { public var profile: LocalProfile public var fullPreferences: FullPreferences public var activeUser: Bool - public var sendRcptsContacts: Bool - public var sendRcptsSmallGroups: Bool public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } @@ -25,6 +23,8 @@ public struct User: Decodable, NamedChat, Identifiable { public var localAlias: String { get { "" } } public var showNtfs: Bool + public var sendRcptsContacts: Bool + public var sendRcptsSmallGroups: Bool public var viewPwdHash: UserPwdHash? public var id: Int64 { userId } @@ -46,9 +46,9 @@ public struct User: Decodable, NamedChat, Identifiable { profile: LocalProfile.sampleData, fullPreferences: FullPreferences.sampleData, activeUser: true, + showNtfs: true, sendRcptsContacts: true, - sendRcptsSmallGroups: false, - showNtfs: true + sendRcptsSmallGroups: false ) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 1b04b1e67..ceee0c175 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -37,6 +37,7 @@ import chat.simplex.app.views.localauth.SetAppPasscodeView import chat.simplex.app.views.newchat.* import chat.simplex.app.views.onboarding.* import chat.simplex.app.views.usersettings.LAMode +import chat.simplex.app.views.usersettings.SetDeliveryReceiptsView import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -469,11 +470,15 @@ fun MainPage( translationX = -offset.value.dp.toPx() } ) { + if (chatModel.setDeliveryReceipts.value) { + SetDeliveryReceiptsView(chatModel) + } else { val stopped = chatModel.chatRunning.value == false if (chatModel.sharedContent.value == null) ChatListView(chatModel, setPerformLA, stopped) else ShareListView(chatModel, stopped) + } } val scope = rememberCoroutineScope() val onComposed: () -> Unit = { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 64994115e..209e1f409 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -71,6 +71,7 @@ class SimplexApp: Application(), LifecycleEventObserver { val user = chatController.apiGetActiveUser() if (user == null) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo chatModel.currentUser.value = null chatModel.users.clear() @@ -81,6 +82,9 @@ class SimplexApp: Application(), LifecycleEventObserver { } else { savedOnboardingStage } + if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) { + chatModel.setDeliveryReceipts.value = true + } chatController.startChat(user) // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt index 522157287..1a76510a0 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -39,6 +39,7 @@ import kotlin.time.* object ChatModel { val controller: ChatController = ChatController val onboardingStage = mutableStateOf(null) + val setDeliveryReceipts = mutableStateOf(false) val currentUser = mutableStateOf(null) val users = mutableStateListOf() val userCreated = mutableStateOf(null) @@ -498,6 +499,8 @@ data class User( val fullPreferences: FullChatPreferences, val activeUser: Boolean, val showNtfs: Boolean, + val sendRcptsContacts: Boolean, + val sendRcptsSmallGroups: Boolean, val viewPwdHash: UserPwdHash? ): NamedChat { override val displayName: String get() = profile.displayName @@ -520,6 +523,8 @@ data class User( fullPreferences = FullChatPreferences.sampleData, activeUser = true, showNtfs = true, + sendRcptsContacts = true, + sendRcptsSmallGroups = false, viewPwdHash = null, ) } @@ -825,7 +830,7 @@ data class Contact( profile = LocalProfile.sampleData, activeConn = Connection.sampleData, contactUsed = true, - chatSettings = ChatSettings(true, false), + chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), userPreferences = ChatPreferences.sampleData, mergedPreferences = ContactUserPreferences.sampleData, createdAt = Clock.System.now(), @@ -974,7 +979,7 @@ data class GroupInfo ( fullGroupPreferences = FullGroupPreferences.sampleData, membership = GroupMember.sampleData, hostConnCustomUserProfileId = null, - chatSettings = ChatSettings(true, false), + chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) @@ -1614,6 +1619,10 @@ data class CIMeta ( fun statusIcon(primaryColor: Color, metaColor: Color = CurrentColors.value.colors.secondary): Pair? = when (itemStatus) { is CIStatus.SndSent -> MR.images.ic_check_filled to metaColor + is CIStatus.SndRcvd -> when(itemStatus.msgRcptStatus) { + MsgReceiptStatus.Ok -> MR.images.ic_double_check to metaColor + MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red + } is CIStatus.SndErrorAuth -> MR.images.ic_close to Color.Red is CIStatus.SndError -> MR.images.ic_warning_filled to WarningYellow is CIStatus.RcvNew -> MR.images.ic_circle_filled to primaryColor @@ -1698,12 +1707,19 @@ fun localTimestamp(t: Instant): String { sealed class CIStatus { @Serializable @SerialName("sndNew") class SndNew: CIStatus() @Serializable @SerialName("sndSent") class SndSent: CIStatus() + @Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus): CIStatus() @Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus() @Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus() @Serializable @SerialName("rcvNew") class RcvNew: CIStatus() @Serializable @SerialName("rcvRead") class RcvRead: CIStatus() } +@Serializable +enum class MsgReceiptStatus { + @SerialName("ok") Ok, + @SerialName("badMsgHash") BadMsgHash; +} + @Serializable sealed class CIDeleted { @Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted() diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index d97b1ae57..eb11ed63c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -93,6 +93,7 @@ class AppPreferences { }, set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) } ) + val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false) val privacyFullBackup = mkBoolPreference(SHARED_PREFS_PRIVACY_FULL_BACKUP, false) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false) @@ -238,6 +239,7 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline" private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode" + private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet" internal const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites" @@ -461,6 +463,18 @@ object ChatController { throw Exception("failed to set the user as active ${r.responseType} ${r.details}") } + suspend fun apiSetAllContactReceipts(enable: Boolean) { + val r = sendCmd(CC.SetAllContactReceipts(enable)) + if (r is CR.CmdOk) return + throw Exception("failed to enable receipts for all users ${r.responseType} ${r.details}") + } + + suspend fun apiSetUserContactReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) { + val r = sendCmd(CC.ApiSetUserContactReceipts(userId, userMsgReceiptSettings)) + if (r is CR.CmdOk) return + throw Exception("failed to enable receipts for user contacts ${r.responseType} ${r.details}") + } + suspend fun apiHideUser(userId: Long, viewPwd: String): User = setUserPrivacy(CC.ApiHideUser(userId, viewPwd)) @@ -699,7 +713,7 @@ object ChatController { } } - suspend fun apiSetSettings(type: ChatType,id: Long, settings: ChatSettings): Boolean { + suspend fun apiSetSettings(type: ChatType, id: Long, settings: ChatSettings): Boolean { val r = sendCmd(CC.APISetChatSettings(type, id, settings)) return when (r) { is CR.CmdOk -> true @@ -1762,6 +1776,8 @@ sealed class CC { class CreateActiveUser(val profile: Profile?, val sameServers: Boolean, val pastTimestamp: Boolean): CC() class ListUsers: CC() class ApiSetActiveUser(val userId: Long, val viewPwd: String?): CC() + class SetAllContactReceipts(val enable: Boolean): CC() + class ApiSetUserContactReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC() class ApiHideUser(val userId: Long, val viewPwd: String): CC() class ApiUnhideUser(val userId: Long, val viewPwd: String): CC() class ApiMuteUser(val userId: Long): CC() @@ -1856,6 +1872,11 @@ sealed class CC { } is ListUsers -> "/users" is ApiSetActiveUser -> "/_user $userId${maybePwd(viewPwd)}" + is SetAllContactReceipts -> "/set receipts all ${onOff(enable)}" + is ApiSetUserContactReceipts -> { + val mrs = userMsgReceiptSettings + "/_set receipts $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}" + } is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}" is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}" is ApiMuteUser -> "/_mute user $userId" @@ -1951,6 +1972,8 @@ sealed class CC { is CreateActiveUser -> "createActiveUser" is ListUsers -> "listUsers" is ApiSetActiveUser -> "apiSetActiveUser" + is SetAllContactReceipts -> "setAllContactReceipts" + is ApiSetUserContactReceipts -> "apiSetUserContactReceipts" is ApiHideUser -> "apiHideUser" is ApiUnhideUser -> "apiUnhideUser" is ApiMuteUser -> "apiMuteUser" @@ -2389,13 +2412,17 @@ data class KeepAliveOpts( @Serializable data class ChatSettings( val enableNtfs: Boolean, + val sendRcpts: Boolean?, val favorite: Boolean ) { companion object { - val defaults: ChatSettings = ChatSettings(enableNtfs = true, favorite = false) + val defaults: ChatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false) } } +@Serializable +data class UserMsgReceiptSettings(val enable: Boolean, val clearOverrides: Boolean) + @Serializable data class FullChatPreferences( val timedMessages: TimedMessagesPreference, diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index 2a7c889fc..311470c1e 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.app.SimplexApp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chatlist.updateChatSettings import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.QRCode import chat.simplex.app.views.usersettings.* @@ -52,15 +53,26 @@ fun ChatInfoView( ) { BackHandler(onBack = close) val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + val currentUser = chatModel.currentUser.value val connStats = remember { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() - if (chat != null) { + if (chat != null && currentUser != null) { val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) { mutableStateOf(chatModel.contactNetworkStatus(contact)) } + val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) } ChatInfoLayout( chat, contact, + currentUser, + sendReceipts = sendReceipts, + setSendReceipts = { sendRcpts -> + withApi { + val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(sendRcpts = sendRcpts.bool) + updateChatSettings(chat, chatSettings, chatModel) + sendReceipts.value = sendRcpts + } + }, connStats = connStats, contactNetworkStatus.value, customUserProfile, @@ -154,6 +166,34 @@ fun ChatInfoView( } } +sealed class SendReceipts { + object Yes: SendReceipts() + object No: SendReceipts() + data class UserDefault(val enable: Boolean): SendReceipts() + + val text: String get() = when (this) { + is Yes -> generalGetString(MR.strings.chat_preferences_yes) + is No -> generalGetString(MR.strings.chat_preferences_no) + is UserDefault -> String.format( + generalGetString(MR.strings.chat_preferences_default), + generalGetString(if (enable) MR.strings.chat_preferences_yes else MR.strings.chat_preferences_no) + ) + } + + val bool: Boolean? get() = when (this) { + is Yes -> true + is No -> false + is UserDefault -> null + } + + companion object { + fun fromBool(enable: Boolean?, userDefault: Boolean): SendReceipts { + return if (enable == null) UserDefault(userDefault) + else if (enable) Yes else No + } + } +} + fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_contact_question), @@ -197,6 +237,9 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit fun ChatInfoLayout( chat: Chat, contact: Contact, + currentUser: User, + sendReceipts: State, + setSendReceipts: (SendReceipts) -> Unit, connStats: MutableState, contactNetworkStatus: NetworkStatus, customUserProfile: Profile?, @@ -240,6 +283,7 @@ fun ChatInfoLayout( VerifyCodeButton(contact.verified, verifyClicked) } ContactPreferencesButton(openPreferences) + SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) if (cStats != null && cStats.ratchetSyncAllowed) { SynchronizeConnectionButton(syncContactConnection) } else if (developerTools) { @@ -499,6 +543,21 @@ private fun ContactPreferencesButton(onClick: () -> Unit) { ) } +@Composable +private fun SendReceiptsOption(currentUser: User, state: State, onSelected: (SendReceipts) -> Unit) { + val values = remember { + mutableListOf(SendReceipts.Yes, SendReceipts.No, SendReceipts.UserDefault(currentUser.sendRcptsContacts)).map { it to it.text } + } + ExposedDropDownSettingRow( + generalGetString(MR.strings.send_receipts), + values, + state, + icon = painterResource(MR.images.ic_double_check), + enabled = remember { mutableStateOf(true) }, + onSelected = onSelected + ) +} + @Composable fun ClearChatButton(onClick: () -> Unit) { SettingsActionItem( @@ -577,6 +636,9 @@ fun PreviewChatInfoLayout() { chatItems = arrayListOf() ), Contact.sampleData, + User.sampleData, + sendReceipts = remember { mutableStateOf(SendReceipts.Yes) }, + setSendReceipts = {}, localAlias = "", connectionCode = "123", developerTools = false, diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index 9404b2436..592f8e6b6 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -10,8 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.* import chat.simplex.app.model.* import chat.simplex.app.ui.theme.CurrentColors import chat.simplex.res.MR @@ -51,7 +50,11 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) { val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color) if (statusIcon != null) { val (icon, statusColor) = statusIcon - StatusIconText(painterResource(icon), statusColor) + if (meta.itemStatus is CIStatus.SndSent || meta.itemStatus is CIStatus.SndRcvd) { + Icon(painterResource(icon), null, Modifier.height(17.dp), tint = statusColor) + } else { + StatusIconText(painterResource(icon), statusColor) + } Spacer(Modifier.width(4.dp)) } else if (!meta.disappearing) { StatusIconText(painterResource(MR.images.ic_circle_filled), Color.Transparent) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt index d4f8d1ecc..899c4cd51 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt @@ -12,7 +12,11 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp @@ -29,6 +33,7 @@ import chat.simplex.app.views.isValidDisplayName import chat.simplex.app.views.localauth.SetAppPasscodeView import chat.simplex.app.views.onboarding.ReadableText import chat.simplex.res.MR +import kotlinx.coroutines.runBlocking enum class LAMode { SYSTEM, @@ -79,6 +84,52 @@ fun PrivacySettingsView( if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) { SectionTextFooter(stringResource(MR.strings.simplex_link_mode_browser_warning)) } + SectionDividerSpaced() + + val currentUser = chatModel.currentUser.value + if (currentUser != null) { + fun setSendReceiptsContacts(enable: Boolean, clearOverrides: Boolean) { + withApi { + val mrs = UserMsgReceiptSettings(enable, clearOverrides) + chatModel.controller.apiSetUserContactReceipts(currentUser.userId, mrs) + chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) + chatModel.currentUser.value = currentUser.copy(sendRcptsContacts = enable) + if (clearOverrides) { + // For loop here is to prevent ConcurrentModificationException that happens with forEach + for (i in 0 until chatModel.chats.size) { + val chat = chatModel.chats[i] + if (chat.chatInfo is ChatInfo.Direct) { + var contact = chat.chatInfo.contact + val sendRcpts = contact.chatSettings.sendRcpts + if (sendRcpts != null && sendRcpts != enable) { + contact = contact.copy(chatSettings = contact.chatSettings.copy(sendRcpts = null)) + chatModel.updateContact(contact) + } + } + } + } + } + } + + DeliveryReceiptsSection( + currentUser = currentUser, + setOrAskSendReceiptsContacts = { enable -> + val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> + if (chat.chatInfo is ChatInfo.Direct) { + val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts + count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + } else { + count + } + } + if (contactReceiptsOverrides == 0) { + setSendReceiptsContacts(enable, clearOverrides = false) + } else { + showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts) + } + } + ) + } SectionBottomSpacer() } } @@ -104,6 +155,70 @@ private fun SimpleXLinkOptions(simplexLinkModeState: State, onS ) } +@Composable +private fun DeliveryReceiptsSection( + currentUser: User, + setOrAskSendReceiptsContacts: (Boolean) -> Unit, +) { + SectionView(stringResource(MR.strings.settings_section_title_delivery_receipts)) { + SettingsActionItemWithContent(painterResource(MR.images.ic_person), stringResource(MR.strings.receipts_section_contacts)) { + DefaultSwitch( + checked = currentUser.sendRcptsContacts ?: false, + onCheckedChange = { enable -> + setOrAskSendReceiptsContacts(enable) + } + ) + } + } + SectionTextFooter( + remember(currentUser.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.receipts_section_description) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser.displayName) + } + append(".\n") + append(generalGetString(MR.strings.receipts_section_description_1)) + } + } + ) +} + +private fun showUserContactsReceiptsAlert( + enable: Boolean, + contactReceiptsOverrides: Int, + setSendReceiptsContacts: (Boolean, Boolean) -> Unit +) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(if (enable) MR.strings.receipts_contacts_title_enable else MR.strings.receipts_contacts_title_disable), + text = AnnotatedString(String.format(generalGetString(if (enable) MR.strings.receipts_contacts_override_disabled else MR.strings.receipts_contacts_override_enabled), contactReceiptsOverrides)), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + setSendReceiptsContacts(enable, false) + }) { + val t = stringResource(if (enable) MR.strings.receipts_contacts_enable_keep_overrides else MR.strings.receipts_contacts_disable_keep_overrides) + Text(t, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + setSendReceiptsContacts(enable, true) + } + ) { + val t = stringResource(if (enable) MR.strings.receipts_contacts_enable_for_all else MR.strings.receipts_contacts_disable_for_all) + Text(t, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.onBackground) + } + } + } + ) +} + private val laDelays = listOf(10, 30, 60, 180, 0) @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SetDeliveryReceiptsView.kt new file mode 100644 index 000000000..04fe102dd --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SetDeliveryReceiptsView.kt @@ -0,0 +1,123 @@ +package chat.simplex.app.views.usersettings + +import SectionBottomSpacer +import android.util.Log +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.style.TextAlign +import chat.simplex.app.TAG +import chat.simplex.app.model.ChatModel +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import chat.simplex.res.MR + +@Composable +fun SetDeliveryReceiptsView(m: ChatModel) { + SetDeliveryReceiptsLayout( + enableReceipts = { + val currentUser = m.currentUser.value + if (currentUser != null) { + withApi { + try { + m.controller.apiSetAllContactReceipts(enable = true) + m.currentUser.value = currentUser.copy(sendRcptsContacts = true) + m.setDeliveryReceipts.value = false + m.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) + try { + val users = m.controller.listUsers() + m.users.clear() + m.users.addAll(users) + } catch (e: Exception) { + Log.e(TAG, "listUsers error: ${e.stackTraceToString()}") + } + } catch (e: Exception) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.error_enabling_delivery_receipts), + text = e.stackTraceToString() + ) + Log.e(TAG, "${generalGetString(MR.strings.error_enabling_delivery_receipts)}: ${e.stackTraceToString()}") + m.setDeliveryReceipts.value = false + } + } + } + }, + skip = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.delivery_receipts_are_disabled), + text = generalGetString(MR.strings.you_can_enable_delivery_receipts_later_alert), + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.dont_show_again), + onConfirm = { + m.setDeliveryReceipts.value = false + }, + onDismiss = { + m.setDeliveryReceipts.value = false + m.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) + } + ) + }, + userCount = m.users.size + ) +} + +@Composable +private fun SetDeliveryReceiptsLayout( + enableReceipts: () -> Unit, + skip: () -> Unit, + userCount: Int, +) { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = DEFAULT_PADDING), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarTitle(stringResource(MR.strings.delivery_receipts_title)) + + Spacer(Modifier.weight(1f)) + + EnableReceiptsButton(enableReceipts) + if (userCount > 1) { + TextBelowButton(stringResource(MR.strings.sending_delivery_receipts_will_be_enabled_all_profiles)) + } else { + TextBelowButton(stringResource(MR.strings.sending_delivery_receipts_will_be_enabled)) + } + + Spacer(Modifier.weight(1f)) + + SkipButton(skip) + + SectionBottomSpacer() + } +} + +@Composable +private fun EnableReceiptsButton(onClick: () -> Unit) { + TextButton(onClick) { + Text(stringResource(MR.strings.enable_receipts_all), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary) + } +} + +@Composable +private fun SkipButton(onClick: () -> Unit) { + SimpleButtonIconEnded(stringResource(MR.strings.dont_enable_receipts), painterResource(MR.images.ic_chevron_right), click = onClick) + TextBelowButton(stringResource(MR.strings.you_can_enable_delivery_receipts_later)) +} + +@Composable +private fun TextBelowButton(text: String) { + Text( + text, + Modifier + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING * 3), + style = MaterialTheme.typography.subtitle1, + textAlign = TextAlign.Center, + ) +} + 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 5b248f51c..bc8cac28c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -864,6 +864,17 @@ An empty chat profile with the provided name is created, and the app opens as usual. If you enter this passcode when opening the app, all app data will be irreversibly removed! Set passcode + These settings are for your current profile + They can be overridden in contact settings + Contacts + Enable receipts? + Disable receipts? + Sending receipts is enabled for %d contacts + Sending receipts is disabled for %d contacts + Enable (keep overrides) + Disable (keep overrides) + Enable for all + Disable for all YOU @@ -873,6 +884,7 @@ APP DEVICE CHATS + SEND DELIVERY RECEIPTS TO Restart Shutdown Developer tools @@ -1144,6 +1156,7 @@ Address Share address You can share this address with your contacts to let them connect with %s. + Send receipts FOR CONSOLE @@ -1487,7 +1500,7 @@ Make one message disappear Even when disabled in the conversation. A few more things - - more stabile message delivery.\n- a bit better groups.\n- and more! + - more stable message delivery.\n- a bit better groups.\n- and more! seconds @@ -1498,4 +1511,15 @@ months Select custom + + + Delivery receipts! + Enable + Sending delivery receipts will be enabled for all contacts in all visible chat profiles. + Sending delivery receipts will be enabled for all contacts. + Don\'t enable + You can enable later via Settings + Delivery receipts are disabled! + You can enable them later via app Privacy & Security settings. + Error enabling delivery receipts! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_double_check.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_double_check.svg new file mode 100644 index 000000000..2c651607d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_double_check.svg @@ -0,0 +1,13 @@ + + + +Created with Fabric.js 5.3.0 + + + + + + + + + \ No newline at end of file