From 7d0768457ebe50c14021e332b4c4e5f2d5eb1587 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 24 Dec 2024 23:56:40 +0000 Subject: [PATCH 01/23] 6.2.2: ios 256, android 263, desktop 84 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 56 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++-- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 759b16b196..910f5d2360 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -516,9 +516,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -671,9 +671,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -754,8 +754,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */, ); path = Libraries; sourceTree = ""; @@ -1931,7 +1931,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1956,7 +1956,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1980,7 +1980,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2005,7 +2005,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2021,11 +2021,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2041,11 +2041,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2066,7 +2066,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2081,7 +2081,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2103,7 +2103,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2118,7 +2118,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2140,7 +2140,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2166,7 +2166,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2191,7 +2191,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2217,7 +2217,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2242,7 +2242,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2257,7 +2257,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2291,7 +2291,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index cde173f24c..00c7578a8e 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.2.1 -android.version_code=261 +android.version_name=6.2.2 +android.version_code=263 -desktop.version_name=6.2.1 -desktop.version_code=83 +desktop.version_name=6.2.2 +desktop.version_code=84 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 400967b03baa1484d0b5c529944a352fc7d6ff84 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 25 Dec 2024 16:54:21 +0000 Subject: [PATCH 02/23] ui: fix saving operators (#5428) --- .../Views/UserSettings/NetworkAndServers/OperatorView.swift | 2 +- apps/ios/SimpleXChat/APITypes.swift | 6 +++--- .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index cea9dd0635..24da6a94a8 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -53,7 +53,7 @@ struct OperatorView: View { ServersErrorView(errStr: errStr) } else { switch (userServers[operatorIndex].operator_.conditionsAcceptance) { - case let .accepted(acceptedAt): + case let .accepted(acceptedAt, _): if let acceptedAt = acceptedAt { Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).") .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 884993f542..ae7e67b32f 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1290,7 +1290,7 @@ public struct ServerOperatorConditions: Decodable { } public enum ConditionsAcceptance: Equatable, Codable, Hashable { - case accepted(acceptedAt: Date?) + case accepted(acceptedAt: Date?, autoAccepted: Bool) // If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator. // No deadline indicates it's required to accept conditions for the operator to start using it. case required(deadline: Date?) @@ -1364,7 +1364,7 @@ public struct ServerOperator: Identifiable, Equatable, Codable { tradeName: "SimpleX Chat", legalName: "SimpleX Chat Ltd", serverDomains: ["simplex.im"], - conditionsAcceptance: .accepted(acceptedAt: nil), + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), enabled: true, smpRoles: ServerRoles(storage: true, proxy: true), xftpRoles: ServerRoles(storage: true, proxy: true) @@ -1397,7 +1397,7 @@ public struct UserOperatorServers: Identifiable, Equatable, Codable { tradeName: "", legalName: "", serverDomains: [], - conditionsAcceptance: .accepted(acceptedAt: nil), + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), enabled: false, smpRoles: ServerRoles(storage: true, proxy: true), xftpRoles: ServerRoles(storage: true, proxy: true) 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 08051927fd..79fd85401e 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 @@ -3757,7 +3757,7 @@ data class ServerOperatorConditionsDetail( @Serializable() sealed class ConditionsAcceptance { - @Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?) : ConditionsAcceptance() + @Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?, val autoAccepted: Boolean) : ConditionsAcceptance() @Serializable @SerialName("required") data class Required(val deadline: Instant?) : ConditionsAcceptance() val conditionsAccepted: Boolean @@ -3801,7 +3801,7 @@ data class ServerOperator( tradeName = "SimpleX Chat", legalName = "SimpleX Chat Ltd", serverDomains = listOf("simplex.im"), - conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null), + conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null, autoAccepted = false), enabled = true, smpRoles = ServerRoles(storage = true, proxy = true), xftpRoles = ServerRoles(storage = true, proxy = true) @@ -3883,7 +3883,7 @@ data class UserOperatorServers( tradeName = "", legalName = null, serverDomains = emptyList(), - conditionsAcceptance = ConditionsAcceptance.Accepted(null), + conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false), enabled = false, smpRoles = ServerRoles(storage = true, proxy = true), xftpRoles = ServerRoles(storage = true, proxy = true) From 00bc59b3a044f7964f379a596e3e43395b23813b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 25 Dec 2024 22:34:55 +0000 Subject: [PATCH 03/23] android: fix for disabled notifications (#5431) * android: fix for disabled notifications * change * prevent showing alert multiple times * changes --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../java/chat/simplex/app/SimplexService.kt | 100 ++++++++++-------- .../SetNotificationsMode.android.kt | 24 +++-- .../chat/simplex/common/model/SimpleXAPI.kt | 2 + .../common/views/chatlist/ChatListView.kt | 6 ++ 4 files changed, 82 insertions(+), 50 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index cb50336fce..6ca8dd43a0 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -470,53 +470,65 @@ class SimplexService: Service() { ) } - private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode, showOffAlert: Boolean) = AlertManager.shared.showAlert { - val ignoreOptimization = { - AlertManager.shared.hideAlert() - askAboutIgnoringBatteryOptimization() + private var showingIgnoreNotification = false + private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode, showOffAlert: Boolean) { + // that's workaround for situation when the app receives onPause/onResume events multiple times + // (for example, after showing system alert for enabling notifications) which triggers showing that alert multiple times + if (showingIgnoreNotification) { + return } - val disableNotifications = { - AlertManager.shared.hideAlert() - disableNotifications(mode, showOffAlert) - } - AlertDialog( - onDismissRequest = disableNotifications, - title = { - Row { - Icon( - painterResource(MR.images.ic_bolt), - contentDescription = - if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications), - ) - Text( - if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications) else stringResource(MR.strings.periodic_notifications), - fontWeight = FontWeight.Bold - ) - } - }, - text = { - Column { - Text( - if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc), - Modifier.padding(bottom = 8.dp) - ) - Text(annotatedStringResource(MR.strings.turn_off_battery_optimization)) - - if (platform.androidIsXiaomiDevice() && (mode == NotificationsMode.PERIODIC || mode == NotificationsMode.SERVICE)) { - Text(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization), - Modifier.padding(top = 8.dp) + showingIgnoreNotification = true + AlertManager.shared.showAlert { + val ignoreOptimization = { + AlertManager.shared.hideAlert() + showingIgnoreNotification = false + askAboutIgnoringBatteryOptimization() + } + val disableNotifications = { + AlertManager.shared.hideAlert() + showingIgnoreNotification = false + disableNotifications(mode, showOffAlert) + } + AlertDialog( + onDismissRequest = disableNotifications, + title = { + Row { + Icon( + painterResource(MR.images.ic_bolt), + contentDescription = + if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications), + ) + Text( + if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications) else stringResource(MR.strings.periodic_notifications), + fontWeight = FontWeight.Bold ) } - } - }, - dismissButton = { - TextButton(onClick = disableNotifications) { Text(stringResource(MR.strings.disable_notifications_button), color = MaterialTheme.colors.error) } - }, - confirmButton = { - TextButton(onClick = ignoreOptimization) { Text(stringResource(MR.strings.turn_off_battery_optimization_button)) } - }, - shape = RoundedCornerShape(corner = CornerSize(25.dp)) - ) + }, + text = { + Column { + Text( + if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc), + Modifier.padding(bottom = 8.dp) + ) + Text(annotatedStringResource(MR.strings.turn_off_battery_optimization)) + + if (platform.androidIsXiaomiDevice() && (mode == NotificationsMode.PERIODIC || mode == NotificationsMode.SERVICE)) { + Text( + annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization), + Modifier.padding(top = 8.dp) + ) + } + } + }, + dismissButton = { + TextButton(onClick = disableNotifications) { Text(stringResource(MR.strings.disable_notifications_button), color = MaterialTheme.colors.error) } + }, + confirmButton = { + TextButton(onClick = ignoreOptimization) { Text(stringResource(MR.strings.turn_off_battery_optimization_button)) } + }, + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) + } } private fun showBGServiceNoticeSystemRestricted(mode: NotificationsMode, showOffAlert: Boolean) = AlertManager.shared.showAlert { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt index a39e71947d..0378fcbd7a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt @@ -4,19 +4,31 @@ import android.Manifest import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import chat.simplex.common.platform.ntfManager -import com.google.accompanist.permissions.PermissionStatus -import com.google.accompanist.permissions.rememberPermissionState +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import com.google.accompanist.permissions.* @Composable actual fun SetNotificationsModeAdditions() { if (Build.VERSION.SDK_INT >= 33) { val notificationsPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) LaunchedEffect(notificationsPermissionState.status == PermissionStatus.Granted) { - if (notificationsPermissionState.status == PermissionStatus.Granted) { - ntfManager.androidCreateNtfChannelsMaybeShowAlert() + val canAsk = appPrefs.canAskToEnableNotifications.get() + if (notificationsPermissionState.status is PermissionStatus.Denied) { + if (notificationsPermissionState.status.shouldShowRationale || !canAsk) { + if (canAsk) { + appPrefs.canAskToEnableNotifications.set(false) + } + Log.w(TAG, "Notifications are disabled and nobody will ask to enable them") + } else { + notificationsPermissionState.launchPermissionRequest() + } } else { - notificationsPermissionState.launchPermissionRequest() + if (!canAsk) { + // the user allowed notifications in system alert or manually in settings, allow to ask him next time if needed + appPrefs.canAskToEnableNotifications.set(true) + } + ntfManager.androidCreateNtfChannelsMaybeShowAlert() } } } else { 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 79fd85401e..f8d67c6f73 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 @@ -80,6 +80,7 @@ class AppPreferences { if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default ) { NotificationsMode.values().firstOrNull { it.name == this } } val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name) + val canAskToEnableNotifications = mkBoolPreference(SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS, true) val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false) val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) @@ -358,6 +359,7 @@ class AppPreferences { private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground" private const val SHARED_PREFS_NOTIFICATIONS_MODE = "NotificationsMode" private const val SHARED_PREFS_NOTIFICATION_PREVIEW_MODE = "NotificationPreviewMode" + private const val SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS = "CanAskToEnableNotifications" private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown" private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown" private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay" 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 ff776bc8ca..2ed6315466 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 @@ -187,6 +187,12 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow Date: Wed, 25 Dec 2024 22:57:04 +0000 Subject: [PATCH 04/23] 6.2.3: ios 257, android 265, desktop 85 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++--- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 910f5d2360..33b0642d2b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1931,7 +1931,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1956,7 +1956,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1980,7 +1980,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2005,7 +2005,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2021,11 +2021,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2041,11 +2041,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2066,7 +2066,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2081,7 +2081,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2103,7 +2103,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2118,7 +2118,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2140,7 +2140,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2166,7 +2166,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2191,7 +2191,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2217,7 +2217,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2242,7 +2242,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2257,7 +2257,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2291,7 +2291,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 00c7578a8e..9af367a5f6 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.2.2 -android.version_code=263 +android.version_name=6.2.3 +android.version_code=265 -desktop.version_name=6.2.2 -desktop.version_code=84 +desktop.version_name=6.2.3 +desktop.version_code=85 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 23b20ac74321aa0ad65a1cbc415e71ecc83e5e37 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 3 Jan 2025 14:52:17 +0700 Subject: [PATCH 05/23] android: fixed scrolling in message text field (#5467) --- .../chat/simplex/common/views/TerminalView.kt | 2 +- .../simplex/common/views/chat/ComposeView.kt | 287 +++++++++--------- 2 files changed, 144 insertions(+), 145 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 6a6db0da85..c2fd52a58c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -88,7 +88,7 @@ fun TerminalLayout( .background(MaterialTheme.colors.background) ) { Divider() - Box(Modifier.padding(horizontal = 8.dp)) { + Surface(Modifier.padding(horizontal = 8.dp), color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { SendMsgView( composeState = composeState, showVoiceRecordIcon = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 3a63cf508e..b0b12a7443 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -918,154 +918,153 @@ fun ComposeView( } } } - Box(Modifier.background(MaterialTheme.colors.background)) { - Divider() - Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) - val attachmentClicked = if (isGroupAndProhibitedFiles) { - { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.files_and_media_prohibited), - text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) + Surface(color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { + Divider() + Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { + val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) + val attachmentClicked = if (isGroupAndProhibitedFiles) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.files_and_media_prohibited), + text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) + ) + } + } else { + showChooseAttachment + } + val attachmentEnabled = + !composeState.value.attachmentDisabled + && sendMsgEnabled.value + && userCanSend.value + && !isGroupAndProhibitedFiles + && !nextSendGrpInv.value + IconButton( + attachmentClicked, + Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), + enabled = attachmentEnabled + ) { + Icon( + painterResource(MR.images.ic_attach_file_filled_500), + contentDescription = stringResource(MR.strings.attach), + tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) ) } - } else { - showChooseAttachment - } - val attachmentEnabled = - !composeState.value.attachmentDisabled - && sendMsgEnabled.value - && userCanSend.value - && !isGroupAndProhibitedFiles - && !nextSendGrpInv.value - IconButton( - attachmentClicked, - Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), - enabled = attachmentEnabled - ) { - Icon( - painterResource(MR.images.ic_attach_file_filled_500), - contentDescription = stringResource(MR.strings.attach), - tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier - .size(28.dp) - .clip(CircleShape) + val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } + LaunchedEffect(allowedVoiceByPrefs) { + if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { + // Voice was disabled right when this user records it, just cancel it + cancelVoice() + } + } + val needToAllowVoiceToContact = remember(chat.chatInfo) { + chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { + ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && + contactPreference.allow == FeatureAllowed.YES + } + } + LaunchedEffect(Unit) { + snapshotFlow { recState.value } + .distinctUntilChanged() + .collect { + when (it) { + is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) + is RecordingState.Finished -> if (it.durationMs > 300) { + onAudioAdded(it.filePath, it.durationMs, true) + } else { + cancelVoice() + } + is RecordingState.NotStarted -> {} + } + } + } + + LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) { + if (!chat.chatInfo.userCanSend) { + clearCurrentDraft() + clearState() + } + } + + KeyChangeEffect(chatModel.chatId.value) { prevChatId -> + val cs = composeState.value + if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { + sendMessage(null) + resetLinkPreview() + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } else if (cs.inProgress) { + clearPrevDraft(prevChatId) + } else if (!cs.empty) { + if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { + composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) + } + if (saveLastDraft) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = prevChatId + } + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { + composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) + } else { + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } + chatModel.removeLiveDummy() + CIFile.cachedRemoteFileRequests.clear() + } + if (appPlatform.isDesktop) { + // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` + DisposableEffect(Unit) { + onDispose { + if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = chat.id + } + } + } + } + val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } + val sendButtonColor = + if (chat.chatInfo.incognito) + if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F) + else MaterialTheme.colors.primary + SendMsgView( + composeState, + showVoiceRecordIcon = true, + recState, + chat.chatInfo is ChatInfo.Direct, + liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, + sendMsgEnabled = sendMsgEnabled.value, + sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), + nextSendGrpInv = nextSendGrpInv.value, + needToAllowVoiceToContact, + allowedVoiceByPrefs, + allowVoiceToContact = ::allowVoiceToContact, + userIsObserver = userIsObserver.value, + userCanSend = userCanSend.value, + sendButtonColor = sendButtonColor, + timedMessageAllowed = timedMessageAllowed, + customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, + placeholder = stringResource(MR.strings.compose_message_placeholder), + sendMessage = { ttl -> + sendMessage(ttl) + resetLinkPreview() + }, + sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null, + updateLiveMessage = ::updateLiveMessage, + cancelLiveMessage = { + composeState.value = composeState.value.copy(liveMessage = null) + chatModel.removeLiveDummy() + }, + editPrevMessage = ::editPrevMessage, + onFilesPasted = { composeState.onFilesAttached(it) }, + onMessageChange = ::onMessageChange, + textStyle = textStyle ) } - val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } - LaunchedEffect(allowedVoiceByPrefs) { - if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { - // Voice was disabled right when this user records it, just cancel it - cancelVoice() - } - } - val needToAllowVoiceToContact = remember(chat.chatInfo) { - chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { - ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && - contactPreference.allow == FeatureAllowed.YES - } - } - LaunchedEffect(Unit) { - snapshotFlow { recState.value } - .distinctUntilChanged() - .collect { - when (it) { - is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) - is RecordingState.Finished -> if (it.durationMs > 300) { - onAudioAdded(it.filePath, it.durationMs, true) - } else { - cancelVoice() - } - is RecordingState.NotStarted -> {} - } - } - } - - LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) { - if (!chat.chatInfo.userCanSend) { - clearCurrentDraft() - clearState() - } - } - - KeyChangeEffect(chatModel.chatId.value) { prevChatId -> - val cs = composeState.value - if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { - sendMessage(null) - resetLinkPreview() - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } else if (cs.inProgress) { - clearPrevDraft(prevChatId) - } else if (!cs.empty) { - if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { - composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) - } - if (saveLastDraft) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = prevChatId - } - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) - } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { - composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) - } else { - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } - chatModel.removeLiveDummy() - CIFile.cachedRemoteFileRequests.clear() - } - if (appPlatform.isDesktop) { - // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` - DisposableEffect(Unit) { - onDispose { - if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = chat.id - } - } - } - } - - val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } - val sendButtonColor = - if (chat.chatInfo.incognito) - if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F) - else MaterialTheme.colors.primary - SendMsgView( - composeState, - showVoiceRecordIcon = true, - recState, - chat.chatInfo is ChatInfo.Direct, - liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, - sendMsgEnabled = sendMsgEnabled.value, - sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), - nextSendGrpInv = nextSendGrpInv.value, - needToAllowVoiceToContact, - allowedVoiceByPrefs, - allowVoiceToContact = ::allowVoiceToContact, - userIsObserver = userIsObserver.value, - userCanSend = userCanSend.value, - sendButtonColor = sendButtonColor, - timedMessageAllowed = timedMessageAllowed, - customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, - placeholder = stringResource(MR.strings.compose_message_placeholder), - sendMessage = { ttl -> - sendMessage(ttl) - resetLinkPreview() - }, - sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null, - updateLiveMessage = ::updateLiveMessage, - cancelLiveMessage = { - composeState.value = composeState.value.copy(liveMessage = null) - chatModel.removeLiveDummy() - }, - editPrevMessage = ::editPrevMessage, - onFilesPasted = { composeState.onFilesAttached(it) }, - onMessageChange = ::onMessageChange, - textStyle = textStyle - ) } } - } } From 4813ab526d33b48830b5c111133772db72c0149d Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 3 Jan 2025 14:53:10 +0700 Subject: [PATCH 06/23] android: limit PiP view size to adapt to Android limitations (#5468) --- .../src/main/java/chat/simplex/app/views/call/CallActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt index a5a1726757..995b584fce 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt @@ -99,7 +99,8 @@ class CallActivity: ComponentActivity(), ServiceConnection { fun setPipParams(video: Boolean, sourceRectHint: Rect? = null, viewRatio: Rational? = null) { // By manually specifying source rect we exclude empty background while toggling PiP val builder = PictureInPictureParams.Builder() - .setAspectRatio(viewRatio) + // that's limitation of Android. Otherwise, may crash on devices like Z Fold 3 + .setAspectRatio(viewRatio?.coerceIn(Rational(100, 239)..Rational(239, 100))) .setSourceRectHint(sourceRectHint) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(video) From 2793692a16ab45d7a81e0e1ce75079ffa36577aa Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 3 Jan 2025 14:53:37 +0700 Subject: [PATCH 07/23] android, desktop: ability to scroll in all alerts if screen is small (#5470) --- .../chat/simplex/common/views/helpers/AlertManager.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 6bfcf2809f..30c5d9cc3c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -297,7 +297,7 @@ private fun AlertContent( belowTextContent: @Composable (() -> Unit) = {}, content: @Composable (() -> Unit) ) { - BoxWithConstraints { + BoxWithConstraints(Modifier.verticalScroll(rememberScrollState())) { Column( Modifier .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) @@ -311,7 +311,6 @@ private fun AlertContent( if (text != null) { Column(Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f) .padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING) - .verticalScroll(rememberScrollState()) ) { SelectionContainer { Text( @@ -334,10 +333,9 @@ private fun AlertContent( @Composable private fun AlertContent(text: AnnotatedString?, hostDevice: Pair?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { - BoxWithConstraints { + BoxWithConstraints(Modifier.verticalScroll(rememberScrollState())) { Column( Modifier - .verticalScroll(rememberScrollState()) .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) ) { if (appPlatform.isDesktop) { @@ -349,7 +347,6 @@ private fun AlertContent(text: AnnotatedString?, hostDevice: Pair if (text != null) { Column( Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f) - .verticalScroll(rememberScrollState()) ) { SelectionContainer { Text( From c9f6f3c0531842a02fdfc418633be211b4822f9b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 4 Jan 2025 18:33:27 +0000 Subject: [PATCH 08/23] core: api and protocol support for reporting messages to group moderators (#5469) * core: api and protocol support for reporting messages to group moderators * moderator role * delete mode * remove auto-accepting conditions for SimpleX Chat Ltd * mark as deleted locally * ui: delete mode type * store msg_content_tag with chat items, support moderator option on receiving side * report API * send reports only to moderators that support them, fail if none support * fix tests * test * remove comment * revert version * do not build ghc8107 in stable branch * skip job * fix condition * remove condition * condition * exit * update --- .github/workflows/build.yml | 5 + apps/ios/SimpleXChat/ChatTypes.swift | 1 + .../chat/simplex/common/model/ChatModel.kt | 1 + docs/rfcs/2024-12-28-reports.md | 84 +++++++++++ docs/rfcs/2024-12-30-content-moderation.md | 136 ++++++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 72 +++++++--- src/Simplex/Chat/Controller.hs | 2 + src/Simplex/Chat/Messages/CIContent.hs | 5 +- .../Chat/Migrations/M20241223_chat_tags.hs | 47 ++++++ .../Chat/Migrations/M20241230_reports.hs | 18 +++ src/Simplex/Chat/Migrations/chat_schema.sql | 33 ++++- src/Simplex/Chat/Operators.hs | 14 +- src/Simplex/Chat/Protocol.hs | 70 +++++++-- src/Simplex/Chat/Store/Groups.hs | 13 +- src/Simplex/Chat/Store/Messages.hs | 8 +- src/Simplex/Chat/Store/Migrations.hs | 6 +- src/Simplex/Chat/Store/Profiles.hs | 8 +- src/Simplex/Chat/Types.hs | 4 +- src/Simplex/Chat/Types/Shared.hs | 3 + tests/ChatTests/Direct.hs | 4 +- tests/ChatTests/Groups.hs | 60 ++++++++ tests/ChatTests/Profiles.hs | 3 +- tests/ProtocolTests.hs | 14 +- 24 files changed, 551 insertions(+), 62 deletions(-) create mode 100644 docs/rfcs/2024-12-28-reports.md create mode 100644 docs/rfcs/2024-12-30-content-moderation.md create mode 100644 src/Simplex/Chat/Migrations/M20241223_chat_tags.hs create mode 100644 src/Simplex/Chat/Migrations/M20241230_reports.hs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7ac10b9cf..2338258d82 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,7 +89,12 @@ jobs: cache_path: C:/cabal asset_name: simplex-chat-windows-x86-64 desktop_asset_name: simplex-desktop-windows-x86_64.msi + steps: + - name: Skip unreliable ghc 8.10.7 build on stable branch + if: matrix.ghc == '8.10.7' && github.ref == 'refs/heads/stable' + run: exit 0 + - name: Configure pagefile (Windows) if: matrix.os == 'windows-latest' uses: al-cheb/configure-pagefile-action@v1.3 diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index da1ce24b73..f8711ff779 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3075,6 +3075,7 @@ public enum CIForwardedFrom: Decodable, Hashable { public enum CIDeleteMode: String, Decodable, Hashable { case cidmBroadcast = "broadcast" case cidmInternal = "internal" + case cidmInternalMark = "internalMark" } protocol ItemContent { 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 d407174e52..f42ace7c7c 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 @@ -2772,6 +2772,7 @@ sealed class CIForwardedFrom { @Serializable enum class CIDeleteMode(val deleteMode: String) { @SerialName("internal") cidmInternal("internal"), + @SerialName("internalMark") cidmInternalMark("internalMark"), @SerialName("broadcast") cidmBroadcast("broadcast"); } diff --git a/docs/rfcs/2024-12-28-reports.md b/docs/rfcs/2024-12-28-reports.md new file mode 100644 index 0000000000..729ad47f19 --- /dev/null +++ b/docs/rfcs/2024-12-28-reports.md @@ -0,0 +1,84 @@ +# Content complaints / reports + +## Problem + +Group moderation is a hard work, particularly when members can join anonymously. + +As groups count and size grows, and as we are moving to working large groups, so will the abuse, so we need report function for active groups that would forward the message that members may find offensive or inappropriate or off-topic or violating any rules that community wants to have. + +It doesn't mean that the moderators must censor everything that is reported, and even less so, that it should be centralized (although in our directory our directory bot would also receive these complaints, and would allow us supporting group owners). + +While we have necessary basic features to remove content and block members, we need to simplify identifying the content both to the group owners and to ourselves, when it comes to the groups listed in directory, or for the groups and files hosted on our servers. + +Having simpler way to report content would also improve the perceived safety of the network for the majority of the users. + +## Solution proposal + +"Report" feature on the messages that would highlight this message to all group admins and moderators. + +Group directory service is also an admin (and will be reduced to moderator in the future), so reported content will be visible to us, so that we can both help group owners to moderate their groups and also to remove the group from directory if necessary. + +To the user who have the new version the reports will be sent as a special event, similar to reaction (or it can be simply an extended reaction?) the usual forwarded messages in the same group, but only to moderators (including admins and owners), with additional flag indicating that this is the report. + +In the clients with the new version the reports could be shown as a flag, possibly with the counter, on group messages that were reported, in the same line where we show emojis. + +If we do that these flags will be seen only by moderators and by the user who submitted the report. When the moderator taps the flag, s/he would see the list of user who reported it, together with the reason. + +The downside of the above UX is that it: +- does not solve the problem of highlighting the problem to admins, particularly if them manage many groups. +- creates confusion about who can see the reports. +- further increases data model complexity, as it requires additional table or self-references (as with quotes), as reports can be received prior to the reported content. +- does not allow admins to see the reported content before it is received by them (would be less important with super-peers). + +Alternatively, and it is probably a better option, all reports, both sent by the users and received by moderators across all groups can be shown in the special subview Reports in each group. The report should be shown as the reported message with the header showing the report reason and the reporter. The report should allow these actions: +- moderate the original message, +- navigate to the original message (requires infinite scrolling, so initially will be only supported on Android and desktop), +- connect to the user who sent the report - it should be possible even if the group prohibits direct messages. There are two options how this communication can be handled - either by creating a new connection, and shown as normal contacts, or as comments to the report, and sent in the same group connection. The latter approach has the advantage that the interface would not be clutter the interace. The former is much simpler, so should probably be offered as MVP. + +This additional chat is necessary, as without it it would be very hard to notice the reports, particularly for the people who moderate multiple groups, and even more so - in our group directory and future super peers. + +## Protocol + +**Option 1** + +The special message `x.msg.report` will be sent in the group with this schema: + +```json +{ + "properties": { + "msgId": {"ref": "base64url"}, + "params": { + "properties": { + "msgId": {"ref": "base64url"}, + "reason": {"enum": ["spam", "illegal", "community", "other"]} + }, + "optionalProperties": { + "memberId": {"ref": "base64url"}, + "comment": {"type": "string"} + } + } + } +} +``` + +The downside is that it does not include the original message, so that the admin cannot act on it before the message is received. + +**Option 2** + +Message quote with the new content type. + +Pro - backwards compatible (quote would include text repeating the reason). + +Con - allows reporting non-existing messages, or even mis-reporting, but it is the same consideration that applies to all quotes. In this case though the admin might moderate the message they did not see yet, and it can be abused to remove appropriate content, so the UI should show warning "do you trust the reporter, as you did not receive the message yet". Moderation via reports may have additional information to ensure that exactly the reported message is moderated - e.g., the receiving client would check that the hash of the message in moderation event matches the hash of one of the messages in history. Possibly this is unnecessary with the view of migration of groups to super-peers. + +The report itself would be a new message content type where the report reason would be repeated as text, for backward compatibility. + +The option 2 seems to be simpler to implement, backward compatible and also more naturally fitting the protocol design - the report is simply a message with the new type that the old clients would be able to show correctly as the usual quote. + +The new clients would have a special presentation of these messages and also merging them into one - e.g. they can be shown as group events on in a more prominent way, but less prominent than the actual messages, and also merge subsequent reports about the same message. + +Given that the old clients would not be able to differentiate the reports and normal replies, and can inadvertently reply to all, we probably should warn the members submitting the report that some of the moderators are running the old version, and give them a choice - send to all or send only to moderators with the new version (or don't send, in case all admins run the old version). + +Having the conversation with the member about their report probably fits with the future comment feature that we should start adding to the backend and to the UI as well, as there is no reasonable backward compatibility for it, and members with the old clients simply won't see the comments, so we will have to release it in two stages and simply not send comments to the members with the old version. + +The model for the comments is a new subtype of MsgContainer, that references the original message and member, but does not include the full message. diff --git a/docs/rfcs/2024-12-30-content-moderation.md b/docs/rfcs/2024-12-30-content-moderation.md new file mode 100644 index 0000000000..e4f21a2d21 --- /dev/null +++ b/docs/rfcs/2024-12-30-content-moderation.md @@ -0,0 +1,136 @@ +# Evolving content moderation + +## Problem + +As the users and groups grow, and particularly given that we are planning to make large (10-100k members) groups work, the abuse will inevitably grow as well. + +Our current approach to content moderation is the following: +- receive a user complaints about the group that violates content guidelines (e.g., most users who send complaints, send them about relatively rare cases of CSAM distribution). This complaint contains the link to join the group, so it is a public group that anybody can join, and there is no expectation of privacy of communications in this group. +- we forward this complaint to our automatic bot joins this group and validates the complaint. +- if the complaint is valid, and the link is hosted on one of the pre-configured servers, then we can disable the link to join the group. +- in addition to that, the bot automatically deletes all files sent to the group, in case they are uploaded to our servers, via secure SSH connection directly to server control port (we don't expose shell access in this way, only to a limited set of server control port commands). + +The problem of CSAM is small at the moment, compared with the network size, but without moderation it would grow, and we need to be ahead of this problem, so this solution was in place since early 2024 - we wrote about it on social media. + +The limitation of this approach is that nothing prevents users who created such group to create a new one, and communicate the link to the new group to the existing members so they can migrate there. While this whack-a-mole game has been working so far, it will not be sustainable once we add support for large groups, so we need to be ahead of this problem again, and implement more efficient solutions. + +At the same time, the advantage of both this solution and of the proposed one is that it achieves removal of CSAM without compromising privacy in any way. Most CSAM distribution in all communication networks happens in publicly accessible channels, and it's the same for SimpleX network. So while as server operators we cannot access any content, as users, anybody can access it, and we, acting as users can use available information to remove this content without any compromise to privacy in security. + +This is covered in our [Privacy Policy](https://simplex.chat/privacy/). + +## Solution + +The solution to prevent further CSAM distribution by the users who did it requires restricting their activity on the client side, and also preventing migration of blocked group to another group. + +Traditionally, communication networks have some form of identification on the server side, and that identification is used to block offending users. + +Innovative SimpleX network design removed the need for persistent user identification of users, and many users see it as an unsolvable dilemma - if we cannot identify the users, then we cannot restrict their actions. + +But it is not true. In the same way we already impose restriction on the sent file size, limiting it to 1gb only on the client-side, we can restrict any user actions on the client side, without having any form of user identification, and without knowing how many users were blocked - we would only know how many blocking actions we applied, but we would not have any information about whether they were applied to one or to many users, in the same way as we don't know whether multiple messaging queues are controlled by one or by multiple users. + +The usual counter-argument is that this can be easily circumvented, because the code is open-source, and the users can modify it, so this approach won't work. While this argument premise is correct, the conclusion that this solution won't be effective is incorrect for two reasons: +- most users are either unable or unwilling to invest time into modifying code. This fact alone makes this solution effective in absolute majority of cases. +- any restriction on communication can be applied both on sending and on receiving client, without the need to identify either of these clients. We already do it with 1gb file restriction - e.g., even if file sender modifies their client to allow sending larger files, most of the recipients won't be able to receive this file anyway, as their clients also restrict the size of file that can be received to 1gb. + +For the group that is blocked to continue functioning, not only message senders have to modify their clients, but also message recipients, which won't happen in the absence of ability to communicate in disabled group. Such groups will only be able to function in an isolated segment of the network, when all users use modified clients and with self-hosted servers, which is outside of our zone of any moral and any potential legal responsibility (while we do not have any responsibility for user-generated content under the existing laws, there are requirements we have to comply with that exist outside of law, e.g. requirements of application stores). + +## Potential changes + +This section is the brain-dump of technically possible changes for the future. They will not be implemented all at once, and this list is neither exhaustive, as we or our users can come up with better ideas, nor committed - some of the ideas below may never be implemented. So these ideas are only listed as technical possibilities. + +Our priority is to continue being able to prevent CSAM distribution as network and groups grow, while doing what is reasonable and minimally possible, to save our costs, to avoid any disruption to the users, and to avoid the reduction in privacy and security - on the opposite, we are planning multiple privacy and security improvements in 2025. + +### Mark files and group links as blocked on the server, with the relevant client action + +Add additional protocol command `BLOCK` that would contain the blocking reason that will be presented to the users who try to connect to the link or to download the file. This would differentiate between "not working" scenarios, when file simply fails to download, and "blocked" scenario, and this simple measure would already reduce any prohibited usage of our servers. This change is likely to be implemented in the near future, to make users aware that we are actively moderating illegal content on the network, to educate users about how we do it without any compromise to their privacy and security, and to increase trust in network reliability, as currently our moderation actions are perceived as "something is broken" by affected users. + +### Extend blocking records on files to include client-side restrictions, and apply them to the client who received this blocking record. + +E.g., the client of the user who uploaded the file would periodically check who this file was received by (this functionality currently does not exist), and during this check the client may find out that the file was blocked. When client finds it out it may do any of the following: +- show a warning that the file violated allowed usage conditions that user agreed to. +- apply restrictions, whether temporary or permanent, to upload further files to servers of this operator only (it would be inappropriate to apply wider restrictions - so we appreciate this comment made by one of the users during the consultation). In case we decide that permanent restrictions should be applied, we could also program the ability to appeal this decision to support team and lift it via unblock code - without the need to have any user identification. + +The downside of this approach is that the client would have to check the file after it is uploaded, which may create additional traffic. But at the same time it would provide file delivery receipts, so overall it could be a valuable, although substantial, change. + +To continue with the file, the clients of the users who attempt to receive the file after it was blocked could do one of the following, depending on the blocking record: +- see the warning that the file is blocked. If CSAM was sent in a group that is not distributing CSAM, this adds comfort and the feeling of safety. +- block image preview, in the same way we block avatars of blocked members. +- users can configure automatic deletion of messages with blocked files. +- refuse, temporarily or permanently, to receive future files and/or messages from this group member. Permanent restriction may be automatically lifted once the member's client presents the proof of being unblocked by server operator. + +Applying the restrictions on the receiving side is technically simpler, and requires only minimal protocol changes mentioned above. + +While file senders can circumvent client side restrictions applied by server operators, these measures can be effective, because the recipients would also have to circumvent them, which is much less likely to happen in a coordinated way. + +The upside of this approach is that it does not compromise users' privacy in any way, and it does not interfere with users rights too. A user voluntarily accepted the Conditions of Use that prohibit upload of illegal content to our servers, so it is in line with the agreement for us to enforce these conditions and restrict functionality in case of conditions being violated. At the same time it would be inappropriate for us to restrict the ability to upload files to the servers of 3rd party operators that are not pre-configured in the app - only these operators should be able to restrict uploads to their servers. + +It also avoids the need for any scanning of content, whether client- or server-side, that would also be an infringement on the users right to privacy under European Convention of Human Rights, article 8. It also makes it unnecessary to identify users, contrary to common belief that to restrict users one needs to identify them. + +In the same way the network design allows delivering user messages without any form of user identification on the network protocol level, which is the innovation that does not exist in any other network, we can apply client-side restrictions on user activities without the need to identify a user. So if the block we apply to a specific piece of content results in client-side upload/download restrictions, all we would know is how many times this restriction was applied, but not to how many users - multiple blocked files could have been all uploaded by one user or by multiple users, but this is not the knowledge that is required to restrict further abuse of our servers and violation of condition of use. Again, this is an innovative approach to moderation that is not present in any of the networks, that allows us both to remain in compliance with the contractual obligations (e.g., with application store owners) and any potential legal obligation (even though the legal advice we have is that we do not have obligation to moderate content, as we are not providing communication services), once it becomes a bigger issue. + +### Extend blocking records on links to include client-side restrictions, and apply them to the clients who received this blocking record. + +Similarly to files, once the link to join the group is blocked, both the owner's client and all members' clients can impose (technically) any of the following restrictions. + +For the owner: +- restrict, temporarily or permanently, ability to create public groups on the servers of the operator (or group of operators, in case of pre-configured operators) who applied this blocking record. +- restrict, temporarily or permanently, ability to upload files to operator's servers. +- restrict, temporarily or permanently, sending any messages to operator's servers, not only in the blocked group. + +For all group members: +- restrict, temporarily or permanently, ability to send and receive messages in the blocked group. + +For the same reason as with files, this measure will be an effective deterrence, even though the code is open-source. + +While full blocking may be seen as draconian, for the people who repeatedly violate the conditions of use, ignoring temporary or limited restrictions, it may be appropriate. The tracking of repeat violations of conditions also does not require any user identification and can be done fully on the client side, with sufficient efficiency. + +### Implement ability to submit reports to group owners and moderators + +This is covered under a [separate RFC](./2024-12-28-reports.md) and is currently in progress. This would improve the ability of group owners to moderate their groups, and would also improve our ability to moderate all listed groups, both manually and automatically, as Directory Service has moderation rights. + +### Implement ability to submit reports to 3rd party server operators + +While users already can send reports to ourselves directly via the app, sending them to other server operators requires additional steps from the users. + +This function would allow sending reports to any server operator directly via the app, to the address sent by the server during the initial connection. + +Server operators may be then offered efficient interfaces in the clients to manage these complaints and to apply client-side restrictions to the users who violate the conditions. + +### Blacklist servers who refuse to remove CSAM from receiving any traffic from our servers + +We cannot and should not enforce that 3rd party server operators remove CSAM from their servers. We will only be recommending it and providing tools to simplify it. + +But we can, technically, implement block-lists of servers so that the users who need to send messages to these servers would not be able to do that via our servers. + +We also can require mandatory server identification to requests to proxy messages via client certificates of the server that could be validated via a reverse connection, and also block incoming traffic from these servers. + +While both these measures are undesirable and would result in network fragmentation, they are technically possible. Similar restrictions already happen in fediverse networks, and they are effective. + +## Actual planned changes + +To summarize, the changes that are planned in the near future: + +- client-side notifications that files or group links were blocked (as opposed to show error, creating an impression that something is not working). +- [content reports](./2024-12-28-reports.md) to group owners and moderators. +- additional short notice about conditions of use that apply to file uploads prior to the first upload. + +Additional simple changes that are considered: + +- applying client-side restriction to create new public groups on operator's servers on admins of blocked groups (do not confuse that with the groups that we decided not to list in our directory, or decided to remove from our directory - this is not blocking that is being discussed here). +- if the group link was registered via directory service, we can prevent further registration of public groups in directory service for this user by, communicating that this link is blocked to directory service. +- preventing any communication in blocked groups. + +To clarify, all these restrictions are considered only for the groups that were created primarily to distribute or to promote CSAM content, they won't apply in cases some group members maliciously posted illegal content in a public group - in which case they will only be applied to this member, helping group owners to moderate. + +We will continue moderating the content as we do now, and as long as CSAM distribution is prevented, we may not need additional measures listed here. + +At the same time, we are committed to make it impossible to distribute CSAM in the part of SimpleX network that we or any other pre-configured operators operate. + +We are also committed to achieve this goal without any reduction in privacy and security even for the affected users. E.g., unless there is an enforceable order, we will not be recording any information identifying the user, such as IP address, because it may inadvertently affect the users whose content was flagged by mistake. + +Our ultimate commitment, and our business is to provide private and secure communication to the users who comply with conditions of use, and to prevent mass-scale surveillance of non-suspects (which is a direct violation of European Convention of Human Rights). + +Privacy and security of the network will further improve in 2025, as we plan: +- adding post-quantum encryption to small groups. +- adding proxying during file reception from unknown (or all) servers. +- adding scheduled and delayed re-broadcasts in large groups, to frustrate timing attacks that could otherwise allow identifying users who send messages to groups. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 37153da27e..4dc755b856 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -156,6 +156,8 @@ library Simplex.Chat.Migrations.M20241128_business_chats Simplex.Chat.Migrations.M20241205_business_chat_members Simplex.Chat.Migrations.M20241222_operator_conditions + Simplex.Chat.Migrations.M20241223_chat_tags + Simplex.Chat.Migrations.M20241230_reports Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8cc3267f3a..8bcfd60adf 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -885,7 +885,7 @@ processChatCommand' vr = \case Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId) fwdItemId) _ -> pure Nothing - APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> case cType of + APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case cType of CTDirect -> withContactLock "sendMessage" chatId $ sendContactContentMessages user chatId live itemTTL (L.map (,Nothing) cms) @@ -895,9 +895,28 @@ processChatCommand' vr = \case CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" - APICreateChatItems folderId cms -> withUser $ \user -> + APICreateChatItems folderId cms -> withUser $ \user -> do + mapM_ assertAllowedContent' cms createNoteFolderContentItems user folderId (L.map (,Nothing) cms) - APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> case cType of + APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> + withGroupLock "reportMessage" gId $ do + (gInfo, ms) <- + withFastStore $ \db -> do + gInfo <- getGroupInfo db vr user gId + (gInfo,) <$> liftIO (getGroupModerators db vr user gInfo) + let ms' = filter compatibleModerator ms + mc = MCReport reportText reportReason + cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc} + when (null ms') $ throwChatError $ CECommandError "no moderators support receiving reports" + sendGroupContentMessages_ user gInfo ms' False Nothing [(cm, Nothing)] + where + compatibleModerator GroupMember {activeConn, memberChatVRange} = + maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= contentReportsVersion + ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do + gId <- withFastStore $ \db -> getGroupIdByName db user groupName + reportedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId contactName_ reportedMessage + processChatCommand $ APIReportMessage gId reportedItemId reportReason "" + APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> assertAllowedContent mc >> case cType of CTDirect -> withContactLock "updateChatItem" chatId $ do ct@Contact {contactId} <- withFastStore $ \db -> getContact db vr user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ @@ -965,6 +984,7 @@ processChatCommand' vr = \case (ct, items) <- getCommandDirectChatItems user chatId itemIds case mode of CIDMInternal -> deleteDirectCIs user ct items True False + CIDMInternalMark -> markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime CIDMBroadcast -> do assertDeletable items assertDirectAllowed user MDSnd ct XMsgDel_ @@ -980,6 +1000,7 @@ processChatCommand' vr = \case ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo case mode of CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime + CIDMInternalMark -> markGroupCIsDeleted user gInfo items True Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier @@ -1010,7 +1031,7 @@ processChatCommand' vr = \case (gInfo@GroupInfo {membership}, items) <- getCommandGroupChatItems user gId itemIds ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo assertDeletable gInfo items - assertUserGroupRole gInfo GRAdmin + assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate let msgMemIds = itemsMsgMemIds gInfo items events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId)) msgMemIds mapM_ (sendGroupMessages user gInfo ms) events @@ -1131,6 +1152,7 @@ processChatCommand' vr = \case MCVideo {text} -> text /= "" MCVoice {text} -> text /= "" MCFile t -> t /= "" + MCReport {} -> True MCUnknown {} -> True APIForwardChatItems (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of CTDirect -> do @@ -1888,6 +1910,7 @@ processChatCommand' vr = \case gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId m <- withFastStore $ \db -> getGroupMember db vr user gId mId let GroupInfo {membership = GroupMember {memberRole = membershipRole}} = gInfo + -- TODO GRModerator when most users migrate when (membershipRole >= GRAdmin) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages let settings = (memberSettings m) {showMessages} processChatCommand $ APISetMemberSettings gId mId settings @@ -2312,6 +2335,7 @@ processChatCommand' vr = \case Nothing -> throwChatError $ CEException "expected to find a single blocked member" Just (bm, remainingMembers) -> do let GroupMember {memberId = bmMemberId, memberRole = bmRole, memberProfile = bmp} = bm + -- TODO GRModerator when most users migrate assertUserGroupRole gInfo $ max GRAdmin bmRole when (blocked == blockedByAdmin bm) $ throwChatError $ CECommandError $ if blocked then "already blocked" else "already unblocked" withGroupLock "blockForAll" groupId . procCmd $ do @@ -3198,6 +3222,12 @@ processChatCommand' vr = \case forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, itemId) _ -> pure () -- prohibited + assertAllowedContent :: MsgContent -> CM () + assertAllowedContent = \case + MCReport {} -> throwChatError $ CECommandError "sending reports via this API is not supported" + _ -> pure () + assertAllowedContent' :: ComposedMessage -> CM () + assertAllowedContent' ComposedMessage {msgContent} = assertAllowedContent msgContent sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse sendContactContentMessages user contactId live itemTTL cmrs = do assertMultiSendable live cmrs @@ -3258,13 +3288,17 @@ processChatCommand' vr = \case sendGroupContentMessages :: User -> GroupId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse sendGroupContentMessages user groupId live itemTTL cmrs = do assertMultiSendable live cmrs - g@(Group gInfo _) <- withFastStore $ \db -> getGroup db vr user groupId + Group gInfo ms <- withFastStore $ \db -> getGroup db vr user groupId + sendGroupContentMessages_ user gInfo ms live itemTTL cmrs + sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse + sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do + assertMultiSendable live cmrs assertUserGroupRole gInfo GRAuthor - assertGroupContentAllowed gInfo - processComposedMessages g + assertGroupContentAllowed + processComposedMessages where - assertGroupContentAllowed :: GroupInfo -> CM () - assertGroupContentAllowed gInfo@GroupInfo {membership} = + assertGroupContentAllowed :: CM () + assertGroupContentAllowed = case findProhibited (L.toList cmrs) of Just f -> throwChatError (CECommandError $ "feature not allowed " <> T.unpack (groupFeatureNameText f)) Nothing -> pure () @@ -3274,8 +3308,8 @@ processChatCommand' vr = \case foldr' (\(ComposedMessage {fileSource, msgContent = mc}, _) acc -> prohibitedGroupContent gInfo membership mc fileSource <|> acc) Nothing - processComposedMessages :: Group -> CM ChatResponse - processComposedMessages g@(Group gInfo ms) = do + processComposedMessages :: CM ChatResponse + processComposedMessages = do (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo itemTTL (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ @@ -3296,7 +3330,7 @@ processChatCommand' vr = \case forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of Just file -> do fileSize <- checkSndFile file - (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup g + (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo ms pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) prepareMsgs :: NonEmpty (ComposeMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTGroup))) @@ -3354,7 +3388,7 @@ processChatCommand' vr = \case case contactOrGroup of CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> withFastStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr - CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) + CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) where -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = @@ -3545,6 +3579,7 @@ quoteContent mc qmc ciFile_ MCImage {} -> True MCVideo {} -> True MCVoice {} -> False + MCReport {} -> False MCUnknown {} -> True qText = msgContentText qmc getFileName :: CIFile d -> String @@ -6076,7 +6111,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo ci groupMsgToView gInfo ci' applyModeration CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} - | moderatorRole < GRAdmin || moderatorRole < memberRole = + | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem | groupFeatureAllowed SGFFullDelete gInfo = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvModerated Nothing timed' False @@ -6161,7 +6196,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CIGroupSnd -> moderate membership cci Left e | msgMemberId == memberId -> messageError $ "x.msg.del: message not found, " <> tshow e - | senderRole < GRAdmin -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e + | senderRole < GRModerator -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e | otherwise -> withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs where moderate :: GroupMember -> CChatItem 'CTGroup -> CM () @@ -6171,7 +6206,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise -> messageError "x.msg.del: message of another member with incorrect memberId" _ -> messageError "x.msg.del: message of another member without memberId" checkRole GroupMember {memberRole} a - | senderRole < GRAdmin || senderRole < memberRole = + | senderRole < GRModerator || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" | otherwise = a delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM ChatResponse @@ -6907,7 +6942,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right bm@GroupMember {groupMemberId = bmId, memberRole, memberProfile = bmp} - | senderRole < GRAdmin || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" + | senderRole < GRModerator || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" | otherwise -> do bm' <- setMemberBlocked bmId toggleNtf user bm' (not blocked) @@ -8404,6 +8439,8 @@ chatCommandP = "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), "/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), + "/_report #" *> (APIReportMessage <$> A.decimal <* A.space <*> A.decimal <*> (" reason=" *> strP) <*> (A.space *> textP <|> pure "")), + "/report #" *> (ReportMessage <$> displayName <*> optional (" @" *> displayName) <*> _strP <* A.space <*> msgTextP), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), @@ -8758,6 +8795,7 @@ chatCommandP = A.choice [ " owner" $> GROwner, " admin" $> GRAdmin, + " moderator" $> GRModerator, " member" $> GRMember, " observer" $> GRObserver ] diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 593c328d0c..318d1939c8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -300,6 +300,8 @@ data ChatCommand | APIGetChatItemInfo ChatRef ChatItemId | APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} | APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage} + | APIReportMessage {groupId :: GroupId, chatItemId :: ChatItemId, reportReason :: ReportReason, reportText :: Text} + | ReportMessage {groupName :: GroupName, contactName_ :: Maybe ContactName, reportReason :: ReportReason, reportedMessage :: Text} | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent} | APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode | APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index e198183b06..6722a5427c 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -103,7 +103,7 @@ msgDirectionIntP = \case 1 -> Just MDSnd _ -> Nothing -data CIDeleteMode = CIDMBroadcast | CIDMInternal +data CIDeleteMode = CIDMBroadcast | CIDMInternal | CIDMInternalMark deriving (Show) $(JQ.deriveJSON (enumJSON $ dropPrefix "CIDM") ''CIDeleteMode) @@ -111,7 +111,8 @@ $(JQ.deriveJSON (enumJSON $ dropPrefix "CIDM") ''CIDeleteMode) ciDeleteModeToText :: CIDeleteMode -> Text ciDeleteModeToText = \case CIDMBroadcast -> "this item is deleted (broadcast)" - CIDMInternal -> "this item is deleted (internal)" + CIDMInternal -> "this item is deleted (locally)" + CIDMInternalMark -> "this item is deleted (locally)" -- This type is used both in API and in DB, so we use different JSON encodings for the database and for the API -- ! Nested sum types also have to use different encodings for database and API diff --git a/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs b/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs new file mode 100644 index 0000000000..a83be7549d --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs @@ -0,0 +1,47 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20241223_chat_tags where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20241223_chat_tags :: Query +m20241223_chat_tags = + [sql| +CREATE TABLE chat_tags ( + chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users, + chat_tag_text TEXT NOT NULL, + chat_tag_emoji TEXT, + tag_order INTEGER NOT NULL +); + +CREATE TABLE chat_tags_chats ( + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE +); + +CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags(user_id, chat_tag_text); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags(user_id, chat_tag_emoji); + +CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats(contact_id, chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats(group_id, chat_tag_id); +|] + +down_m20241223_chat_tags :: Query +down_m20241223_chat_tags = + [sql| +DROP INDEX idx_chat_tags_user_id; +DROP INDEX idx_chat_tags_user_id_chat_tag_text; +DROP INDEX idx_chat_tags_user_id_chat_tag_emoji; + +DROP INDEX idx_chat_tags_chats_chat_tag_id; +DROP INDEX idx_chat_tags_chats_chat_tag_id_contact_id; +DROP INDEX idx_chat_tags_chats_chat_tag_id_group_id; + +DROP TABLE chat_tags_chats; +DROP TABLE chat_tags; +|] diff --git a/src/Simplex/Chat/Migrations/M20241230_reports.hs b/src/Simplex/Chat/Migrations/M20241230_reports.hs new file mode 100644 index 0000000000..d5b3c183cf --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20241230_reports.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + + module Simplex.Chat.Migrations.M20241230_reports where + + import Database.SQLite.Simple (Query) + import Database.SQLite.Simple.QQ (sql) + + m20241230_reports :: Query + m20241230_reports = + [sql| +ALTER TABLE chat_items ADD COLUMN msg_content_tag TEXT; +|] + + down_m20241230_reports :: Query + down_m20241230_reports = + [sql| +ALTER TABLE chat_items DROP COLUMN msg_content_tag; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 0a6a581cbe..ee968383e0 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -402,7 +402,8 @@ CREATE TABLE chat_items( fwd_from_contact_id INTEGER REFERENCES contacts ON DELETE SET NULL, fwd_from_group_id INTEGER REFERENCES groups ON DELETE SET NULL, fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL, - via_proxy INTEGER + via_proxy INTEGER, + msg_content_tag TEXT ); CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( @@ -625,6 +626,18 @@ CREATE TABLE operator_usage_conditions( , auto_accepted INTEGER DEFAULT 0 ); +CREATE TABLE chat_tags( + chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users, + chat_tag_text TEXT NOT NULL, + chat_tag_emoji TEXT, + tag_order INTEGER NOT NULL +); +CREATE TABLE chat_tags_chats( + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -931,3 +944,21 @@ CREATE INDEX idx_chat_items_notes ON chat_items( created_at ); CREATE INDEX idx_groups_business_xcontact_id ON groups(business_xcontact_id); +CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags( + user_id, + chat_tag_text +); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags( + user_id, + chat_tag_emoji +); +CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats( + contact_id, + chat_tag_id +); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats( + group_id, + chat_tag_id +); diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 9eda85aaf3..07225856b6 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -300,22 +300,22 @@ newUserServer_ preset enabled server = UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False} -- This function should be used inside DB transaction to update conditions in the database --- it evaluates to (conditions to mark as accepted to SimpleX operator, current conditions, and conditions to add) -usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +-- it evaluates to (current conditions, and conditions to add) +usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (UsageConditions, [UsageConditions]) usageConditionsToAdd = usageConditionsToAdd' previousConditionsCommit usageConditionsCommit -- This function is used in unit tests -usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (UsageConditions, [UsageConditions]) usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case [] - | newUser -> (Just sourceCond, sourceCond, [sourceCond]) - | otherwise -> (Just prevCond, sourceCond, [prevCond, sourceCond]) + | newUser -> (sourceCond, [sourceCond]) + | otherwise -> (sourceCond, [prevCond, sourceCond]) where prevCond = conditions 1 prevCommit sourceCond = conditions 2 sourceCommit conds - | hasSourceCond -> (Nothing, last conds, []) - | otherwise -> (Nothing, sourceCond, [sourceCond]) + | hasSourceCond -> (last conds, []) + | otherwise -> (sourceCond, [sourceCond]) where hasSourceCond = any ((sourceCommit ==) . conditionsCommit) conds sourceCond = conditions cId sourceCommit diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 934f23007d..89664d66f7 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -37,7 +37,7 @@ import Data.Maybe (fromMaybe, mapMaybe) import Data.String import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (decodeLatin1, encodeUtf8) +import Data.Text.Encoding (decodeASCII', decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Type.Equality import Data.Typeable (Typeable) @@ -48,6 +48,7 @@ import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared +import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) import Simplex.Messaging.Compression (Compressed, compress1, decompress1) import Simplex.Messaging.Encoding @@ -69,12 +70,13 @@ import Simplex.Messaging.Version hiding (version) -- 9 - batch sending in direct connections (2024-07-24) -- 10 - business chats (2024-11-29) -- 11 - fix profile update in business chats (2024-12-05) +-- 12 - fix profile update in business chats (2025-01-03) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 11 +currentChatVersion = VersionChat 12 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -121,6 +123,10 @@ businessChatsVersion = VersionChat 10 businessChatPrefsVersion :: VersionChat businessChatPrefsVersion = VersionChat 11 +-- support sending and receiving content reports (MCReport message content) +contentReportsVersion :: VersionChat +contentReportsVersion = VersionChat 12 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion @@ -246,6 +252,9 @@ data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknown {tag :: Text, json :: J.Object} deriving (Eq, Show) +data ReportReason = RRSpam | RRContent | RRCommunity | RRProfile | RROther | RRUnknown Text + deriving (Eq, Show) + $(pure []) instance FromJSON LinkContent where @@ -265,6 +274,30 @@ instance ToJSON LinkContent where $(JQ.deriveJSON defaultJSON ''LinkPreview) +instance StrEncoding ReportReason where + strEncode = \case + RRSpam -> "spam" + RRContent -> "content" + RRCommunity -> "community" + RRProfile -> "profile" + RROther -> "other" + RRUnknown t -> encodeUtf8 t + strP = + A.takeTill (== ' ') >>= \case + "spam" -> pure RRSpam + "content" -> pure RRContent + "community" -> pure RRCommunity + "profile" -> pure RRProfile + "other" -> pure RROther + t -> maybe (fail "bad ReportReason") (pure . RRUnknown) $ decodeASCII' t + +instance FromJSON ReportReason where + parseJSON = strParseJSON "ReportReason" + +instance ToJSON ReportReason where + toJSON = strToJSON + toEncoding = strToJEncoding + data ChatMessage e = ChatMessage { chatVRange :: VersionRangeChat, msgId :: Maybe SharedMsgId, @@ -451,7 +484,7 @@ cmToQuotedMsg = \case ACME _ (XMsgNew (MCQuote quotedMsg _)) -> Just quotedMsg _ -> Nothing -data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVideo_ | MCVoice_ | MCFile_ | MCUnknown_ Text +data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVideo_ | MCVoice_ | MCFile_ | MCReport_ | MCUnknown_ Text deriving (Eq) instance StrEncoding MsgContentTag where @@ -462,6 +495,7 @@ instance StrEncoding MsgContentTag where MCVideo_ -> "video" MCFile_ -> "file" MCVoice_ -> "voice" + MCReport_ -> "report" MCUnknown_ t -> encodeUtf8 t strDecode = \case "text" -> Right MCText_ @@ -470,6 +504,7 @@ instance StrEncoding MsgContentTag where "video" -> Right MCVideo_ "voice" -> Right MCVoice_ "file" -> Right MCFile_ + "report" -> Right MCReport_ t -> Right . MCUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') @@ -480,6 +515,10 @@ instance ToJSON MsgContentTag where toJSON = strToJSON toEncoding = strToJEncoding +instance FromField MsgContentTag where fromField = fromBlobField_ strDecode + +instance ToField MsgContentTag where toField = toField . strEncode + data MsgContainer = MCSimple ExtMsgContent | MCQuote QuotedMsg ExtMsgContent @@ -504,6 +543,7 @@ data MsgContent | MCVideo {text :: Text, image :: ImageData, duration :: Int} | MCVoice {text :: Text, duration :: Int} | MCFile Text + | MCReport {text :: Text, reason :: ReportReason} | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) @@ -518,6 +558,10 @@ msgContentText = \case where msg = "voice message " <> durationText duration MCFile t -> t + MCReport {text, reason} -> + if T.null text then msg else msg <> ": " <> text + where + msg = "report " <> safeDecodeUtf8 (strEncode reason) MCUnknown {text} -> text toMCText :: MsgContent -> MsgContent @@ -532,16 +576,9 @@ durationText duration = | otherwise = show n msgContentHasText :: MsgContent -> Bool -msgContentHasText = \case - MCText t -> hasText t - MCLink {text} -> hasText text - MCImage {text} -> hasText text - MCVideo {text} -> hasText text - MCVoice {text} -> hasText text - MCFile t -> hasText t - MCUnknown {text} -> hasText text - where - hasText = not . T.null +msgContentHasText = not . T.null . \case + MCVoice {text} -> text + mc -> msgContentText mc isVoice :: MsgContent -> Bool isVoice = \case @@ -556,6 +593,7 @@ msgContentTag = \case MCVideo {} -> MCVideo_ MCVoice {} -> MCVoice_ MCFile {} -> MCFile_ + MCReport {} -> MCReport_ MCUnknown {tag} -> MCUnknown_ tag data ExtMsgContent = ExtMsgContent {content :: MsgContent, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool} @@ -654,6 +692,10 @@ instance FromJSON MsgContent where duration <- v .: "duration" pure MCVoice {text, duration} MCFile_ -> MCFile <$> v .: "text" + MCReport_ -> do + text <- v .: "text" + reason <- v .: "reason" + pure MCReport {text, reason} MCUnknown_ tag -> do text <- fromMaybe unknownMsgType <$> v .:? "text" pure MCUnknown {tag, text, json = v} @@ -681,6 +723,7 @@ instance ToJSON MsgContent where MCVideo {text, image, duration} -> J.object ["type" .= MCVideo_, "text" .= text, "image" .= image, "duration" .= duration] MCVoice {text, duration} -> J.object ["type" .= MCVoice_, "text" .= text, "duration" .= duration] MCFile t -> J.object ["type" .= MCFile_, "text" .= t] + MCReport {text, reason} -> J.object ["type" .= MCReport_, "text" .= text, "reason" .= reason] toEncoding = \case MCUnknown {json} -> JE.value $ J.Object json MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t @@ -689,6 +732,7 @@ instance ToJSON MsgContent where MCVideo {text, image, duration} -> J.pairs $ "type" .= MCVideo_ <> "text" .= text <> "image" .= image <> "duration" .= duration MCVoice {text, duration} -> J.pairs $ "type" .= MCVoice_ <> "text" .= text <> "duration" .= duration MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t + MCReport {text, reason} -> J.pairs $ "type" .= MCReport_ <> "text" .= text <> "reason" .= reason instance ToField MsgContent where toField = toField . encodeJSON diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 36ce7f3575..51d23cf1e9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -49,6 +49,7 @@ module Simplex.Chat.Store.Groups getGroupMemberById, getGroupMemberByMemberId, getGroupMembers, + getGroupModerators, getGroupMembersForExpiration, getGroupCurrentMembersCount, deleteGroupConnectionsAndFiles, @@ -740,8 +741,16 @@ getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) <$> DB.query db - (groupMemberQuery <> " WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") - (userId, groupId, userId, userContactId) + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") + (userId, userId, groupId, userContactId) + +getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] +getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember vr user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND member_role IN (?,?,?)") + (userId, userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 13cadcca77..01ae7b7c28 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -405,21 +405,21 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q -- user and IDs user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, -- meta - item_sent, item_ts, item_content, item_content_tag, item_text, item_status, shared_msg_id, + item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. itemRow :. quoteRow :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 7d4d96dff2..8710641b33 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -120,6 +120,8 @@ import Simplex.Chat.Migrations.M20241125_indexes import Simplex.Chat.Migrations.M20241128_business_chats import Simplex.Chat.Migrations.M20241205_business_chat_members import Simplex.Chat.Migrations.M20241222_operator_conditions +import Simplex.Chat.Migrations.M20241223_chat_tags +import Simplex.Chat.Migrations.M20241230_reports import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -239,7 +241,9 @@ schemaMigrations = ("20241125_indexes", m20241125_indexes, Just down_m20241125_indexes), ("20241128_business_chats", m20241128_business_chats, Just down_m20241128_business_chats), ("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members), - ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions) + ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions), + ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags), + ("20241230_reports", m20241230_reports, Just down_m20241230_reports) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 013075841e..252f468130 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -617,18 +617,14 @@ getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> getUpdateServerOperators db presetOps newUser = do conds <- map toUsageConditions <$> DB.query_ db usageCondsQuery now <- getCurrentTime - let (acceptForSimplex_, currentConds, condsToAdd) = usageConditionsToAdd newUser now conds + let (currentConds, condsToAdd) = usageConditionsToAdd newUser now conds mapM_ insertConditions condsToAdd latestAcceptedConds_ <- getLatestAcceptedConditions db ops <- updatedServerOperators presetOps <$> getServerOperators_ db forM ops $ traverse $ mapM $ \(ASO _ op) -> -- traverse for tuple, mapM for Maybe case operatorId op of - DBNewEntity -> do - op' <- insertOperator op - case (operatorTag op', acceptForSimplex_) of - (Just OTSimplex, Just cond) -> autoAcceptConditions op' cond now - _ -> pure op' + DBNewEntity -> insertOperator op DBEntityId _ -> do updateOperator op getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 77a02a4bc1..d5b45b35c0 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -411,12 +411,12 @@ data GroupSummary = GroupSummary } deriving (Show) -data ContactOrGroup = CGContact Contact | CGGroup Group +data ContactOrGroup = CGContact Contact | CGGroup GroupInfo [GroupMember] contactAndGroupIds :: ContactOrGroup -> (Maybe ContactId, Maybe GroupId) contactAndGroupIds = \case CGContact Contact {contactId} -> (Just contactId, Nothing) - CGGroup (Group GroupInfo {groupId} _) -> (Nothing, Just groupId) + CGGroup GroupInfo {groupId} _ -> (Nothing, Just groupId) -- TODO when more settings are added we should create another type to allow partial setting updates (with all Maybe properties) data ChatSettings = ChatSettings diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index f44457160f..4601fe4e4a 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -16,6 +16,7 @@ data GroupMemberRole = GRObserver -- connects to all group members and receives all messages, can't send messages | GRAuthor -- reserved, unused | GRMember -- + can send messages to all group members + | GRModerator -- + moderate messages and block members (excl. Admins and Owners) | GRAdmin -- + add/remove members, change member role (excl. Owners) | GROwner -- + delete and change group information, add/remove/change roles for Owners deriving (Eq, Show, Ord) @@ -28,12 +29,14 @@ instance StrEncoding GroupMemberRole where strEncode = \case GROwner -> "owner" GRAdmin -> "admin" + GRModerator -> "moderator" GRMember -> "member" GRAuthor -> "author" GRObserver -> "observer" strDecode = \case "owner" -> Right GROwner "admin" -> Right GRAdmin + "moderator" -> Right GRModerator "member" -> Right GRMember "author" -> Right GRAuthor "observer" -> Right GRObserver diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 95d27801f6..960b4bd96e 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1231,14 +1231,14 @@ testOperators = alice ##> "/_conditions" alice <##. "Current conditions: 2." alice ##> "/_operators" - alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required" alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" alice <##. "The new conditions will be accepted for SimpleX Chat Ltd at " -- set conditions notified alice ##> "/_conditions_notified 2" alice <## "ok" alice ##> "/_operators" - alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required" alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" alice ##> "/_conditions" alice <##. "Current conditions: 2 (notified)." diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a185b0038e..7f1a9d3e56 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -175,6 +175,8 @@ chatGroupTests = do it "can't repeat block, unblock" testBlockForAllCantRepeat describe "group member inactivity" $ do it "mark member inactive on reaching quota" testGroupMemberInactive + describe "group member reports" $ do + it "should send report to group owner, admins and moderators, but not other users" testGroupMemberReports where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -6540,3 +6542,61 @@ testGroupMemberInactive tmp = do { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] } } + +testGroupMemberReports :: HasCallStack => FilePath -> IO () +testGroupMemberReports = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup3 "jokes" alice bob cath + alice ##> "/mr jokes bob moderator" + concurrentlyN_ + [ alice <## "#jokes: you changed the role of bob from admin to moderator", + bob <## "#jokes: alice changed your role from admin to moderator", + cath <## "#jokes: alice changed the role of bob from admin to moderator" + ] + alice ##> "/mr jokes cath member" + concurrentlyN_ + [ alice <## "#jokes: you changed the role of cath from admin to member", + bob <## "#jokes: alice changed the role of cath from admin to member", + cath <## "#jokes: alice changed your role from admin to member" + ] + alice ##> "/create link #jokes" + gLink <- getGroupLink alice "jokes" GRMember True + dan ##> ("/c " <> gLink) + dan <## "connection request sent!" + concurrentlyN_ + [ do + alice <## "dan (Daniel): accepting request to join group #jokes..." + alice <## "#jokes: dan joined the group", + do + dan <## "#jokes: joining the group..." + dan <## "#jokes: you joined the group" + dan <### + [ "#jokes: member bob (Bob) is connected", + "#jokes: member cath (Catherine) is connected" + ], + do + bob <## "#jokes: alice added dan (Daniel) to the group (connecting...)" + bob <## "#jokes: new member dan is connected", + do + cath <## "#jokes: alice added dan (Daniel) to the group (connecting...)" + cath <## "#jokes: new member dan is connected" + ] + cath #> "#jokes inappropriate joke" + concurrentlyN_ + [ alice <# "#jokes cath> inappropriate joke", + bob <# "#jokes cath> inappropriate joke", + dan <# "#jokes cath> inappropriate joke" + ] + dan ##> "/report #jokes content inappropriate joke" + dan <# "#jokes > cath inappropriate joke" + dan <## " report content" + concurrentlyN_ + [ do + alice <# "#jokes dan> > cath inappropriate joke" + alice <## " report content", + do + bob <# "#jokes dan> > cath inappropriate joke" + bob <## " report content", + (cath show currentChatVersion <> ")") groupInfo a = do a <## "group ID: 1" a <## "current members: 1" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 523df81ade..aba67ed034 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-11\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-12\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -182,6 +182,12 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ) ) ) + it "x.msg.new report" $ + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"reason\":\"spam\",\"type\":\"report\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" + ##==## ChatMessage + chatInitialVRange + (Just $ SharedMsgId "\1\2\3\4") + (XMsgNew (MCQuote quotedMsg (extMsgContent (MCReport "" RRSpam) Nothing))) it "x.msg.new forward with file" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) @@ -243,13 +249,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -264,7 +270,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" From 8b5bc44106f93ee82c5308cd726ea0be41719d1d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 4 Jan 2025 19:18:55 +0000 Subject: [PATCH 09/23] core: remove duplicate check when sending message --- src/Simplex/Chat.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8bcfd60adf..f92b60dbc1 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3292,7 +3292,6 @@ processChatCommand' vr = \case sendGroupContentMessages_ user gInfo ms live itemTTL cmrs sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do - assertMultiSendable live cmrs assertUserGroupRole gInfo GRAuthor assertGroupContentAllowed processComposedMessages From 38db2d075d57f15578528f1949fc9b6333bd3c15 Mon Sep 17 00:00:00 2001 From: Diogo Date: Mon, 6 Jan 2025 16:42:46 +0000 Subject: [PATCH 10/23] android, desktop: types/api for reports (#5483) * android, desktop: types/api for reports * extra char * data object -> object * change --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../chat/simplex/common/model/ChatModel.kt | 54 +++++++++++++++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 14 +++++ .../simplex/common/views/chat/ComposeView.kt | 2 + .../commonMain/resources/MR/base/strings.xml | 1 + 4 files changed, 71 insertions(+) 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 f42ace7c7c..b9bf51f3f1 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 @@ -3321,6 +3321,7 @@ sealed class MsgContent { @Serializable(with = MsgContentSerializer::class) class MCVideo(override val text: String, val image: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCReport(override val text: String, val reason: ReportReason): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val isVoice: Boolean get() = @@ -3397,6 +3398,10 @@ object MsgContentSerializer : KSerializer { element("MCFile", buildClassSerialDescriptor("MCFile") { element("text") }) + element("MCReport", buildClassSerialDescriptor("MCReport") { + element("text") + element("reason") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -3427,6 +3432,10 @@ object MsgContentSerializer : KSerializer { MsgContent.MCVoice(text, duration) } "file" -> MsgContent.MCFile(text) + "report" -> { + val reason = Json.decodeFromString(json["reason"].toString()) + MsgContent.MCReport(text, reason) + } else -> MsgContent.MCUnknown(t, text, json) } } else { @@ -3475,6 +3484,12 @@ object MsgContentSerializer : KSerializer { put("type", "file") put("text", value.text) } + is MsgContent.MCReport -> + buildJsonObject { + put("type", "report") + put("text", value.text) + put("reason", json.encodeToJsonElement(value.reason)) + } is MsgContent.MCUnknown -> value.json } encoder.encodeJsonElement(json) @@ -3569,6 +3584,45 @@ enum class FormatColor(val color: String) { } } + +@Serializable(with = ReportReasonSerializer::class) +sealed class ReportReason { + @Serializable @SerialName("spam") object Spam: ReportReason() + @Serializable @SerialName("illegal") object Illegal: ReportReason() + @Serializable @SerialName("community") object Community: ReportReason() + @Serializable @SerialName("profile") object Profile: ReportReason() + @Serializable @SerialName("other") object Other: ReportReason() + @Serializable @SerialName("unknown") data class Unknown(val type: String): ReportReason() +} + +object ReportReasonSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("ReportReason", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ReportReason { + return when (val value = decoder.decodeString()) { + "spam" -> ReportReason.Spam + "illegal" -> ReportReason.Illegal + "community" -> ReportReason.Community + "profile" -> ReportReason.Profile + "other" -> ReportReason.Other + else -> ReportReason.Unknown(value) + } + } + + override fun serialize(encoder: Encoder, value: ReportReason) { + val stringValue = when (value) { + is ReportReason.Spam -> "spam" + is ReportReason.Illegal -> "illegal" + is ReportReason.Community -> "community" + is ReportReason.Profile -> "profile" + is ReportReason.Other -> "other" + is ReportReason.Unknown -> value.type + } + encoder.encodeString(stringValue) + } +} + @Serializable class SndFileTransfer() {} 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 f8d67c6f73..e78472d2d4 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 @@ -941,6 +941,17 @@ object ChatController { } } + suspend fun apiReportMessage(rh: Long?, groupId: Long, chatItemId: Long, reportReason: ReportReason, reportText: String): List? { + val r = sendCmd(rh, CC.ApiReportMessage(groupId, chatItemId, reportReason, reportText)) + return when (r) { + is CR.NewChatItems -> r.chatItems + else -> { + apiErrorAlert("apiReportMessage", generalGetString(MR.strings.error_creating_report), r) + null + } + } + } + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? { return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) { is CR.ApiChatItemInfo -> r.chatItemInfo @@ -3159,6 +3170,7 @@ sealed class CC { class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): CC() + class ApiReportMessage(val groupId: Long, val chatItemId: Long, val reportReason: ReportReason, val reportText: String): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC() @@ -3321,6 +3333,7 @@ sealed class CC { val msgs = json.encodeToString(composedMessages) "/_create *$noteFolderId json $msgs" } + is ApiReportMessage -> "/_report #$groupId $chatItemId reason=$reportReason $reportText" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" @@ -3478,6 +3491,7 @@ sealed class CC { is ApiGetChatItemInfo -> "apiGetChatItemInfo" is ApiSendMessages -> "apiSendMessages" is ApiCreateChatItems -> "apiCreateChatItems" + is ApiReportMessage -> "apiReportMessage" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index b0b12a7443..2247a615df 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -170,6 +170,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { is MsgContent.MCVideo -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true) is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName)) + is MsgContent.MCReport -> ComposePreview.NoPreview is MsgContent.MCUnknown, null -> ComposePreview.NoPreview } } @@ -483,6 +484,7 @@ fun ComposeView( is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration) is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration) is MsgContent.MCFile -> MsgContent.MCFile(msgText) + is MsgContent.MCReport -> MsgContent.MCReport(msgText, reason = msgContent.reason) is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json) } } 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 b052727ad2..22ad02a52e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -139,6 +139,7 @@ Error sending message Error forwarding messages Error creating message + Error creating report Error loading details Error adding member(s) Error joining group From e3e5d9646c598ff28a38c30bea710c9d8764f4b2 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 6 Jan 2025 20:14:31 +0000 Subject: [PATCH 11/23] core: fix delete api #5484 --- src/Simplex/Chat.hs | 3 +-- src/Simplex/Chat/Messages/CIContent.hs | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index f92b60dbc1..ca8ec60c41 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -8441,7 +8441,7 @@ chatCommandP = "/_report #" *> (APIReportMessage <$> A.decimal <* A.space <*> A.decimal <*> (" reason=" *> strP) <*> (A.space *> textP <|> pure "")), "/report #" *> (ReportMessage <$> displayName <*> optional (" @" *> displayName) <*> _strP <* A.space <*> msgTextP), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), - "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode), + "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), @@ -8724,7 +8724,6 @@ chatCommandP = <|> (PTBefore <$ "before=" <*> strP <* A.space <* "count=" <*> A.decimal) mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP - ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal chatDeleteMode = A.choice [ " full" *> (CDMFull <$> notifyP), diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 6722a5427c..96a4f9f6c8 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -17,6 +17,7 @@ module Simplex.Chat.Messages.CIContent where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ +import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -106,7 +107,24 @@ msgDirectionIntP = \case data CIDeleteMode = CIDMBroadcast | CIDMInternal | CIDMInternalMark deriving (Show) -$(JQ.deriveJSON (enumJSON $ dropPrefix "CIDM") ''CIDeleteMode) +instance StrEncoding CIDeleteMode where + strEncode = \case + CIDMBroadcast -> "broadcast" + CIDMInternal -> "internal" + CIDMInternalMark -> "internalMark" + strP = + A.takeTill (== ' ') >>= \case + "broadcast" -> pure CIDMBroadcast + "internal" -> pure CIDMInternal + "internalMark" -> pure CIDMInternalMark + _ -> fail "bad CIDeleteMode" + +instance ToJSON CIDeleteMode where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromJSON CIDeleteMode where + parseJSON = strParseJSON "CIDeleteMode" ciDeleteModeToText :: CIDeleteMode -> Text ciDeleteModeToText = \case From f33a9650bc12d18d362bd0a9a9bb6ad9e281df82 Mon Sep 17 00:00:00 2001 From: Diogo Date: Tue, 7 Jan 2025 20:58:22 +0000 Subject: [PATCH 12/23] android, desktop: fix previous years display on chat view (#5486) --- .../commonMain/kotlin/chat/simplex/common/model/ChatModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b9bf51f3f1..ac33917588 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 @@ -2540,7 +2540,7 @@ fun getTimestampDateText(t: Instant): String { val time = t.toLocalDateTime(tz).toJavaLocalDateTime() val weekday = time.format(DateTimeFormatter.ofPattern("EEE")) val dayMonthYear = time.format(DateTimeFormatter.ofPattern( - if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM YYYY") + if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM yyyy") ) return "$weekday, $dayMonthYear" From 8dc29082d5125e4ce13d218feb135e675c1d059e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 8 Jan 2025 13:31:32 +0400 Subject: [PATCH 13/23] core: fix auto-accepting conditions for simplex operator (#5489) --- src/Simplex/Chat/Store/Profiles.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 252f468130..5f72915701 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -628,7 +628,6 @@ getUpdateServerOperators db presetOps newUser = do DBEntityId _ -> do updateOperator op getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case - CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds now CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds now ca -> pure op {conditionsAcceptance = ca} where From 7e344b3ee88ee84a2f85bddb288f5ea825e2ad7c Mon Sep 17 00:00:00 2001 From: Diogo Date: Wed, 8 Jan 2025 18:28:45 +0000 Subject: [PATCH 14/23] ios: reports inline (#5466) * initial types * changes types * decode * possible mock for inline report * remove avatar * diff * updates * parser and display message * send messages and support placeholder * profile reports and all reports working * new api * check member support for receiving reports * report chat item text * moderator role * placeholder on text compose for report * rename method * remove need to have reported item in memory to action * archived reports * changes/fix * fix block member * delete and moderate * archive * report reason * context menu/moderation fixes * typo * not needed * report reason as caption, and change text * remove auto archive * move placeholder to match text * prefix red italic report * archive * apply mark deleted fix * Revert "apply mark deleted fix" This reverts commit b12f14c0f54af84d53a7d6c61e1fd316c950e085. * remove extra space * context menu rework * strings, icons * recheck items extra check on reports * simplify * simpler * reports: never show for own messages, disable attachments, hide when recording or live * style, allow local deletion --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/SimpleXAPI.swift | 18 +- .../Views/Chat/ChatItem/FramedItemView.swift | 23 +- .../Chat/ChatItem/MarkedDeletedItemView.swift | 14 +- .../Views/Chat/ChatItem/MsgContentView.swift | 10 +- apps/ios/Shared/Views/Chat/ChatView.swift | 260 ++++++++++++++++-- .../Chat/ComposeMessage/ComposeView.swift | 93 ++++++- .../Chat/ComposeMessage/ContextItemView.swift | 5 +- .../ComposeMessage/NativeTextEditor.swift | 30 ++ .../Chat/ComposeMessage/SendMessageView.swift | 3 + .../Chat/Group/AddGroupMembersView.swift | 6 +- .../Chat/Group/GroupMemberInfoView.swift | 34 ++- .../Chat/SelectableChatItemToolbars.swift | 8 +- .../Views/ChatList/ChatPreviewView.swift | 23 +- apps/ios/SimpleXChat/APITypes.swift | 6 + apps/ios/SimpleXChat/ChatTypes.swift | 172 ++++++++++-- .../chat/simplex/common/model/ChatModel.kt | 6 +- .../chat/simplex/common/model/SimpleXAPI.kt | 5 +- 17 files changed, 617 insertions(+), 99 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 51be3191ec..ae9f0e3e19 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -15,12 +15,6 @@ import SimpleXChat private var chatController: chat_ctrl? -// currentChatVersion in core -public let CURRENT_CHAT_VERSION: Int = 2 - -// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core) -public let CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion: 2, maxVersion: CURRENT_CHAT_VERSION) - private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network-statuses.lock") enum TerminalItem: Identifiable { @@ -418,6 +412,18 @@ func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage] return nil } +func apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) async -> [ChatItem]? { + let r = await chatSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText)) + if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } } + + logger.error("apiReportMessage error: \(String(describing: r))") + AlertManager.shared.showAlertMsg( + title: "Error creating report", + message: "Error: \(responseError(r))" + ) + return nil +} + private func sendMessageErrorAlert(_ r: ChatResponse) { logger.error("send message error: \(String(describing: r))") AlertManager.shared.showAlertMsg( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 9b71e6c4a4..6da893d1d2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -30,7 +30,17 @@ struct FramedItemView: View { var body: some View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { - if let di = chatItem.meta.itemDeleted { + if chatItem.isReport { + if chatItem.meta.itemDeleted == nil { + let txt = chatItem.chatDir.sent ? + Text("Only you and moderators see it") : + Text("Only sender and moderators see it") + + framedItemHeader(icon: "flag", iconColor: .red, caption: txt.italic()) + } else { + framedItemHeader(icon: "flag", caption: Text("archived report").italic()) + } + } else if let di = chatItem.meta.itemDeleted { switch di { case let .moderated(_, byGroupMember): framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic()) @@ -144,6 +154,8 @@ struct FramedItemView: View { } case let .file(text): ciFileView(chatItem, text) + case let .report(text, reason): + ciMsgContentView(chatItem, Text(text.isEmpty ? reason.text : "\(reason.text): ").italic().foregroundColor(.red)) case let .link(_, preview): CILinkView(linkPreview: preview) ciMsgContentView(chatItem) @@ -159,13 +171,14 @@ struct FramedItemView: View { } } - @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View { + @ViewBuilder func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View { let v = HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) + .foregroundColor(iconColor ?? theme.colors.secondary) } caption .font(.caption) @@ -228,7 +241,6 @@ struct FramedItemView: View { .overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } } .frame(minWidth: msgWidth, alignment: .leading) .background(chatItemFrameContextColor(chatItem, theme)) - if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { v.frame(maxWidth: mediaWidth, alignment: .leading) } else { @@ -281,7 +293,7 @@ struct FramedItemView: View { } } - @ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View { + @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ txtPrefix: Text? = nil) -> some View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let ft = text == "" ? [] : ci.formattedText @@ -291,7 +303,8 @@ struct FramedItemView: View { formattedText: ft, meta: ci.meta, rightToLeft: rtl, - showSecrets: showSecrets + showSecrets: showSecrets, + prefix: txtPrefix )) .multilineTextAlignment(rtl ? .trailing : .leading) .padding(.vertical, 6) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index c2b4021edc..87a9b2ce61 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -67,11 +67,15 @@ struct MarkedDeletedItemView: View { // same texts are in markedDeletedText in ChatPreviewView, but it returns String; // can be refactored into a single function if functions calling these are changed to return same type var markedDeletedText: LocalizedStringKey { - switch chatItem.meta.itemDeleted { - case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" - case .blocked: "blocked" - case .blockedByAdmin: "blocked by admin" - case .deleted, nil: "marked deleted" + if chatItem.meta.itemDeleted != nil, chatItem.isReport { + "archived report" + } else { + switch chatItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" + case .blocked: "blocked" + case .blockedByAdmin: "blocked by admin" + case .deleted, nil: "marked deleted" + } } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 914f7c8a2f..e9b6d0ba84 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -34,6 +34,7 @@ struct MsgContentView: View { var meta: CIMeta? = nil var rightToLeft = false var showSecrets: Bool + var prefix: Text? = nil @State private var typingIdx = 0 @State private var timer: Timer? @@ -67,7 +68,7 @@ struct MsgContentView: View { } private func msgContentView() -> Text { - var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary) + var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) if let mt = meta { if mt.isLive { v = v + typingIndicator(mt.recent) @@ -89,9 +90,10 @@ struct MsgContentView: View { } } -func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text { +func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { let s = text var res: Text + if let ft = formattedText, ft.count > 0 && ft.count <= 200 { res = formatText(ft[0], preview, showSecret: showSecrets) var i = 1 @@ -106,6 +108,10 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St if let i = icon { res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res } + + if let p = prefix { + res = p + res + } if let s = sender { let t = Text(s) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 90c277ce76..285bdf2d98 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -917,6 +917,7 @@ struct ChatView: View { @State private var allowMenu: Bool = true @State private var markedRead = false + @State private var actionSheet: SomeActionSheet? = nil var revealed: Bool { chatItem == revealedChatItem } @@ -1001,6 +1002,7 @@ struct ChatView: View { } } } + .actionSheet(item: $actionSheet) { $0.actionSheet } } private func unreadItemIds(_ range: ClosedRange) -> [ChatItem.ID] { @@ -1208,7 +1210,7 @@ struct ChatView: View { Button("Delete for me", role: .destructive) { deleteMessage(.cidmInternal, moderate: false) } - if let di = deletingItem, di.meta.deletable && !di.localNote { + if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport { Button(broadcastDeleteButtonText(chat), role: .destructive) { deleteMessage(.cidmBroadcast, moderate: false) } @@ -1282,7 +1284,21 @@ struct ChatView: View { @ViewBuilder private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View { - if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { + if let groupInfo = chat.chatInfo.groupInfo, ci.isReport, ci.meta.itemDeleted == nil { + if ci.chatDir == .groupSnd { + deleteButton(ci) + } else { + archiveReportButton(ci) + if let qi = ci.quotedItem { + moderateReportedButton(qi, ci, groupInfo) + if let rMember = qi.memberToModerate(chat.chatInfo) { + if !rMember.blockedByAdmin, rMember.canBlockForAll(groupInfo: groupInfo) { + blockMemberButton(rMember, groupInfo, qi, ci) + } + } + } + } + } else if let mc = ci.content.msgContent, !ci.isReport, ci.meta.itemDeleted == nil || revealed { if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, availableReactions.count > 0 { reactionsGroup @@ -1332,8 +1348,12 @@ struct ChatView: View { if !live || !ci.meta.isLive { deleteButton(ci) } - if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo), ci.chatDir != .groupSnd { - moderateButton(ci, groupInfo) + if ci.chatDir != .groupSnd { + if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { + moderateButton(ci, groupInfo) + } else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole < .moderator, !live, composeState.voiceMessageRecordingState == .noRecording { + reportButton(ci) + } } } else if ci.meta.itemDeleted != nil { if revealed { @@ -1648,19 +1668,10 @@ struct ChatView: View { private func moderateButton(_ ci: ChatItem, _ groupInfo: GroupInfo) -> Button { Button(role: .destructive) { - AlertManager.shared.showAlert(Alert( - title: Text("Delete member message?"), - message: Text( - groupInfo.fullGroupPreferences.fullDelete.on - ? "The message will be deleted for all members." - : "The message will be marked as moderated for all members." - ), - primaryButton: .destructive(Text("Delete")) { - deletingItem = ci - deleteMessage(.cidmBroadcast, moderate: true) - }, - secondaryButton: .cancel() - )) + showModerateMessageAlert(groupInfo) { + deletingItem = ci + deleteMessage(.cidmBroadcast, moderate: true) + } } label: { Label( NSLocalizedString("Moderate", comment: "chat item action"), @@ -1668,6 +1679,112 @@ struct ChatView: View { ) } } + + private func moderateReportedButton(_ rItem: CIQuote, _ reportItem: ChatItem, _ groupInfo: GroupInfo) -> Button { + Button(role: .destructive) { + showModerateMessageAlert(groupInfo) { + Task { + let deleted = await deleteReportedMessage(rItem, reportItem.id, groupInfo) + if deleted != nil { + await MainActor.run { + deletingItem = reportItem + deleteMessage(.cidmInternalMark, moderate: false) + } + } + } + } + } label: { + Label( + NSLocalizedString("Moderate", comment: "chat item action"), + systemImage: "flag" + ) + } + } + + private func archiveReportButton(_ cItem: ChatItem) -> Button { + Button(role: .destructive) { + AlertManager.shared.showAlert( + Alert( + title: Text("Archive report?"), + message: Text("The report will be archived for all moderators and reporter."), + primaryButton: .destructive(Text("Archive")) { + deletingItem = cItem + deleteMessage(.cidmInternalMark, moderate: false) + }, + secondaryButton: .cancel() + ) + ) + } label: { + Label( + NSLocalizedString("Archive", comment: "chat item action"), + systemImage: "archivebox" + ) + } + } + + private func blockMemberButton(_ member: GroupMember, _ groupInfo: GroupInfo, _ rItem: CIQuote, _ report: ChatItem) -> Button { + Button(role: .destructive) { + actionSheet = SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Block and moderate?"), + buttons: [ + .destructive(Text("Block and moderate")) { + AlertManager.shared.showAlert( + Alert( + title: Text("Delete member message and block?"), + message: Text( + NSLocalizedString( + groupInfo.fullGroupPreferences.fullDelete.on + ? "The message will be deleted for all members.\nAll new messages from \(member.chatViewName) will be hidden!" + : "The message will be marked as moderated for all members.\n All new messages from \(member.chatViewName) will be hidden!" + , comment: "block and moderate action" + ) + ), + primaryButton: .destructive(Text("Delete and block")) { + Task { + let deleted = await deleteReportedMessage(rItem, report.id, groupInfo) + if deleted != nil { + let blocked = await blockMemberForAll(groupInfo, member, true) + + if blocked != nil { + await MainActor.run { + deletingItem = report + deleteMessage(.cidmInternalMark, moderate: false) + } + } + } + } + }, + secondaryButton: .cancel() + ) + ) + }, + .destructive(Text("Only block")) { + Task { + if (await getLocalIdForReportedMessage(rItem, report.id, groupInfo)) != nil { + AlertManager.shared.showAlert( + blockForAllAlert(groupInfo, member) { + deletingItem = report + deleteMessage(.cidmInternalMark, moderate: false) + } + ) + } else { + showNoMessageMessageAlert() + } + } + }, + .cancel() + ] + ), + id: "blockMember" + ) + } label: { + Label( + NSLocalizedString("Block member", comment: "chat item action"), + systemImage: "hand.raised" + ) + } + } private func revealButton(_ ci: ChatItem) -> Button { Button { @@ -1707,7 +1824,38 @@ struct ChatView: View { ) } } - + + private func reportButton(_ ci: ChatItem) -> Button { + Button(role: .destructive) { + var buttons: [ActionSheet.Button] = ReportReason.supportedReasons.map { reason in + .default(Text(reason.text)) { + withAnimation { + if composeState.editing { + composeState = ComposeState(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason)) + } else { + composeState = composeState.copy(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason)) + } + } + } + } + + buttons.append(.cancel()) + + actionSheet = SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Report reason?"), + buttons: buttons + ), + id: "reportChatMessage" + ) + } label: { + Label ( + NSLocalizedString("Report", comment: "chat item action"), + systemImage: "flag" + ) + } + } + var deleteMessagesTitle: LocalizedStringKey { let n = deletingItems.count return n == 1 ? "Delete message?" : "Delete \(n) messages?" @@ -1738,6 +1886,60 @@ struct ChatView: View { itemIds.forEach { selectedChatItems?.remove($0) } } } + + private func deleteReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> ChatItemDeletion? { + do { + let itemId = await getLocalIdForReportedMessage(rItem, reportId, groupInfo) + + if let itemId = itemId { + let deletedItem = try await apiDeleteMemberChatItems( + groupId: groupInfo.apiId, + itemIds: [itemId] + ).first + + if let di = deletedItem { + await MainActor.run { + if let toItem = di.toChatItem { + _ = m.upsertChatItem(chat.chatInfo, toItem.chatItem) + } else { + m.removeChatItem(chat.chatInfo, di.deletedChatItem.chatItem) + } + } + + return di + } + } else { + showNoMessageMessageAlert() + } + } catch { + logger.error("ChatView.deleteReportedMessage error: \(error)") + AlertManager.shared.showAlertMsg(title: LocalizedStringKey("Error"), message: LocalizedStringKey("Failed to delete reported message")) + } + + return nil + } + + private func getLocalIdForReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> Int64? { + do { + if let itemId = rItem.itemId { + return itemId + } else { + let reportItem = try await apiGetChatItems( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + pagination: .around(chatItemId: reportId, count: 0) + ).first + + if let itemId = reportItem?.quotedItem?.itemId { + return itemId + } + } + } catch { + logger.error("ChatView.getLocalIdForReportedMessage error: \(error)") + } + + return nil + } private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) { logger.debug("ChatView deleteMessage") @@ -1772,7 +1974,7 @@ struct ChatView: View { } } } catch { - logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + logger.error("ChatView.deleteMessage error: \(error)") } } } @@ -1812,6 +2014,26 @@ struct ChatView: View { } } +private func showModerateMessageAlert(_ groupInfo: GroupInfo, _ onModerate: @escaping () -> Void) { + AlertManager.shared.showAlert(Alert( + title: Text("Delete member message?"), + message: Text( + groupInfo.fullGroupPreferences.fullDelete.on + ? "The message will be deleted for all members." + : "The message will be marked as moderated for all members." + ), + primaryButton: .destructive(Text("Delete"), action: onModerate), + secondaryButton: .cancel() + )) +} + +private func showNoMessageMessageAlert() { + AlertManager.shared.showAlertMsg( + title: LocalizedStringKey("No message"), + message: LocalizedStringKey("This message was deleted or not received yet.") + ) +} + private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 19e2b528f1..a68a4987a1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -24,6 +24,7 @@ enum ComposeContextItem { case quotedItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem) case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo) + case reportedItem(chatItem: ChatItem, reason: ReportReason) } enum VoiceMessageRecordingState { @@ -116,13 +117,31 @@ struct ComposeState { default: return false } } - + + var reporting: Bool { + switch contextItem { + case .reportedItem: return true + default: return false + } + } + + var submittingValidReport: Bool { + switch contextItem { + case let .reportedItem(_, reason): + switch reason { + case .other: return !message.isEmpty + default: return true + } + default: return false + } + } + var sendEnabled: Bool { switch preview { case let .mediaPreviews(media): return !media.isEmpty case .voicePreview: return voiceMessageRecordingState == .finished case .filePreview: return true - default: return !message.isEmpty || forwarding || liveMessage != nil + default: return !message.isEmpty || forwarding || liveMessage != nil || submittingValidReport } } @@ -175,7 +194,7 @@ struct ComposeState { } var attachmentDisabled: Bool { - if editing || forwarding || liveMessage != nil || inProgress { return true } + if editing || forwarding || liveMessage != nil || inProgress || reporting { return true } switch preview { case .noPreview: return false case .linkPreview: return false @@ -193,6 +212,15 @@ struct ComposeState { } } + var placeholder: String? { + switch contextItem { + case let .reportedItem(_, reason): + return reason.text + default: + return nil + } + } + var empty: Bool { message == "" && noPreview } @@ -297,6 +325,11 @@ struct ComposeView: View { ContextInvitingContactMemberView() Divider() } + + if case let .reportedItem(_, reason) = composeState.contextItem { + reportReasonView(reason) + Divider() + } // preference checks should match checks in forwarding list let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) @@ -686,6 +719,27 @@ struct ComposeView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(.thinMaterial) } + + + private func reportReasonView(_ reason: ReportReason) -> some View { + let reportText = switch reason { + case .spam: NSLocalizedString("Report spam: only group moderators will see it.", comment: "report reason") + case .profile: NSLocalizedString("Report member profile: only group moderators will see it.", comment: "report reason") + case .community: NSLocalizedString("Report violation: only group moderators will see it.", comment: "report reason") + case .illegal: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason") + case .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason") + case .unknown: "" // Should never happen + } + + return Text(reportText) + .italic() + .font(.caption) + .padding(12) + .frame(minHeight: 44) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial) + } + @ViewBuilder private func contextItemView() -> some View { switch composeState.contextItem { @@ -715,6 +769,15 @@ struct ComposeView: View { cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } ) Divider() + case let .reportedItem(chatItem: reportedItem, _): + ContextItemView( + chat: chat, + contextItems: [reportedItem], + contextIcon: "flag", + cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, + contextIconForeground: Color.red + ) + Divider() } } @@ -746,6 +809,8 @@ struct ComposeView: View { sent = await updateMessage(ci, live: live) } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { sent = await updateMessage(liveMessage.chatItem, live: live) + } else if case let .reportedItem(chatItem, reason) = composeState.contextItem { + sent = await send(reason, chatItemId: chatItem.id) } else { var quoted: Int64? = nil if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem { @@ -872,6 +937,8 @@ struct ComposeView: View { return .voice(text: msgText, duration: duration) case .file: return .file(msgText) + case .report(_, let reason): + return .report(text: msgText, reason: reason) case .unknown(let type, _): return .unknown(type: type, text: msgText) } @@ -891,7 +958,25 @@ struct ComposeView: View { return nil } } - + + func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? { + if let chatItems = await apiReportMessage( + groupId: chat.chatInfo.apiId, + chatItemId: chatItemId, + reportReason: reportReason, + reportText: msgText + ) { + await MainActor.run { + for chatItem in chatItems { + chatModel.addChatItem(chat.chatInfo, chatItem) + } + } + return chatItems.first + } + + return nil + } + func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { await send( [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)], diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index fa999961fc..3cb747ec68 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -15,6 +15,7 @@ struct ContextItemView: View { let contextItems: [ChatItem] let contextIcon: String let cancelContextItem: () -> Void + var contextIconForeground: Color? = nil var showSender: Bool = true var body: some View { @@ -23,7 +24,7 @@ struct ContextItemView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) - .foregroundColor(theme.colors.secondary) + .foregroundColor(contextIconForeground ?? theme.colors.secondary) if let singleItem = contextItems.first, contextItems.count == 1 { if showSender, let sender = singleItem.memberDisplayName { VStack(alignment: .leading, spacing: 4) { @@ -93,6 +94,6 @@ struct ContextItemView: View { struct ContextItemView_Previews: PreviewProvider { static var previews: some View { let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") - return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}) + return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}, contextIconForeground: Color.red) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index ad47b7351a..2fc122f249 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var disableEditing: Bool @Binding var height: CGFloat @Binding var focused: Bool + @Binding var placeholder: String? let onImagesAdded: ([UploadContent]) -> Void private let minHeight: CGFloat = 37 @@ -50,6 +51,7 @@ struct NativeTextEditor: UIViewRepresentable { field.setOnFocusChangedListener { focused = $0 } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + field.setPlaceholderView() updateFont(field) updateHeight(field) return field @@ -62,6 +64,11 @@ struct NativeTextEditor: UIViewRepresentable { updateFont(field) updateHeight(field) } + + let castedField = field as! CustomUITextField + if castedField.placeholder != placeholder { + castedField.placeholder = placeholder + } } private func updateHeight(_ field: UITextView) { @@ -97,11 +104,18 @@ private class CustomUITextField: UITextView, UITextViewDelegate { var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onFocusChanged: (Bool) -> Void = { focused in } + private let placeholderLabel: UILabel = UILabel() + init(height: Binding) { self.height = height super.init(frame: .zero, textContainer: nil) } + var placeholder: String? { + get { placeholderLabel.text } + set { placeholderLabel.text = newValue } + } + required init?(coder: NSCoder) { fatalError("Not implemented") } @@ -124,6 +138,20 @@ private class CustomUITextField: UITextView, UITextViewDelegate { func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { self.onTextChanged = onTextChanged } + + func setPlaceholderView() { + placeholderLabel.textColor = .lightGray + placeholderLabel.font = UIFont.preferredFont(forTextStyle: .body) + placeholderLabel.isHidden = !text.isEmpty + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(placeholderLabel) + + NSLayoutConstraint.activate([ + placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7), + placeholderLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7), + placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8) + ]) + } func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { self.onFocusChanged = onFocusChanged @@ -172,6 +200,7 @@ private class CustomUITextField: UITextView, UITextViewDelegate { } func textViewDidChange(_ textView: UITextView) { + placeholderLabel.isHidden = !text.isEmpty if textView.markedTextRange == nil { var images: [UploadContent] = [] var rangeDiff = 0 @@ -217,6 +246,7 @@ struct NativeTextEditor_Previews: PreviewProvider{ disableEditing: Binding.constant(false), height: Binding.constant(100), focused: Binding.constant(false), + placeholder: Binding.constant("Placeholder"), onImagesAdded: { _ in } ) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 8880023e02..fb69dfdd17 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -61,6 +61,7 @@ struct SendMessageView: View { disableEditing: $composeState.inProgress, height: $teHeight, focused: $keyboardVisible, + placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), onImagesAdded: onMediaAdded ) .allowsTightening(false) @@ -105,6 +106,8 @@ struct SendMessageView: View { let vmrs = composeState.voiceMessageRecordingState if nextSendGrpInv { inviteMemberContactButton() + } else if case .reportedItem = composeState.contextItem { + sendMessageButton() } else if showVoiceMessageButton && composeState.message.isEmpty && !composeState.editing diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index bdef8d0a62..66fe67a29e 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -175,10 +175,8 @@ struct AddGroupMembersViewCommon: View { private func rolePicker() -> some View { Picker("New member role", selection: $selectedRole) { - ForEach(GroupMemberRole.allCases) { role in - if role <= groupInfo.membership.memberRole && role != .author { - Text(role.text) - } + ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in + Text(role.text) } } .frame(height: 36) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index a18de1b349..58e22e63a2 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -296,7 +296,7 @@ struct GroupMemberInfoView: View { } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { if let contactId = member.memberContactId { newDirectChatButton(contactId, width: buttonWidth) - } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false { + } else if member.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION { createMemberContactButton(width: buttonWidth) } InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert() @@ -764,12 +764,18 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet } } -func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { +func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember, _ onBlocked: (() -> Void)? = nil) -> Alert { Alert( title: Text("Block member for all?"), message: Text("All new messages from \(mem.chatViewName) will be hidden!"), primaryButton: .destructive(Text("Block for all")) { - blockMemberForAll(gInfo, mem, true) + Task { + let uMember = await blockMemberForAll(gInfo, mem, true) + + if uMember != nil { + onBlocked?() + } + } }, secondaryButton: .cancel() ) @@ -780,23 +786,25 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { title: Text("Unblock member for all?"), message: Text("Messages from \(mem.chatViewName) will be shown!"), primaryButton: .default(Text("Unblock for all")) { - blockMemberForAll(gInfo, mem, false) + Task { + await blockMemberForAll(gInfo, mem, false) + } }, secondaryButton: .cancel() ) } -func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) { - Task { - do { - let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) - await MainActor.run { - _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) - } - } catch let error { - logger.error("apiBlockMemberForAll error: \(responseError(error))") +func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) async -> GroupMember? { + do { + let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) } + return updatedMember + } catch let error { + logger.error("apiBlockMemberForAll error: \(responseError(error))") } + return nil } struct GroupMemberInfoView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 7b185d8211..81498ee497 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -116,10 +116,10 @@ struct SelectedItemsBottomToolbar: View { if selected.contains(ci.id) { var (de, dee, me, onlyOwnGroupItems, fe, sel) = r de = de && ci.canBeDeletedForSelf - dee = dee && ci.meta.deletable && !ci.localNote - onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd - me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil - fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy + dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport + onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport + me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport + fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isReport sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list return (de, dee, me, onlyOwnGroupItems, fe, sel) } else { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 13701a40a2..a311db7d50 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -248,16 +248,20 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> Text { let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil - return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) + return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix()) // same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; // can be refactored into a single function if functions calling these are changed to return same type func markedDeletedText() -> String { - switch cItem.meta.itemDeleted { - case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName) - case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text") - case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text") - case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text") + if cItem.meta.itemDeleted != nil, cItem.isReport { + "archived report" + } else { + switch cItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName) + case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text") + case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text") + case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text") + } } } @@ -270,6 +274,13 @@ struct ChatPreviewView: View { default: return nil } } + + func prefix() -> Text { + switch cItem.content.msgContent { + case let .report(text, reason): return Text(!text.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red) + default: return Text("") + } + } } @ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index ae7e67b32f..71a5132d12 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -45,6 +45,7 @@ public enum ChatCommand { case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) + case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) @@ -209,6 +210,8 @@ public enum ChatCommand { case let .apiCreateChatItems(noteFolderId, composedMessages): let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" + case let .apiReportMessage(groupId, chatItemId, reportReason, reportText): + return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)" case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" @@ -372,6 +375,7 @@ public enum ChatCommand { case .apiGetChatItemInfo: return "apiGetChatItemInfo" case .apiSendMessages: return "apiSendMessages" case .apiCreateChatItems: return "apiCreateChatItems" + case .apiReportMessage: return "apiReportMessage" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" @@ -1162,12 +1166,14 @@ public enum ChatPagination { case last(count: Int) case after(chatItemId: Int64, count: Int) case before(chatItemId: Int64, count: Int) + case around(chatItemId: Int64, count: Int) var cmdString: String { switch self { case let .last(count): return "count=\(count)" case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" + case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)" } } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f8711ff779..2638c56776 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -9,6 +9,12 @@ import Foundation import SwiftUI +// version to establishing direct connection with a group member (xGrpDirectInvVRange in core) +public let CREATE_MEMBER_CONTACT_VERSION = 2 + +// version to receive reports (MCReport) +public let REPORTS_VERSION = 12 + public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 public var agentUserId: String @@ -1678,7 +1684,7 @@ public struct Connection: Decodable, Hashable { static let sampleData = Connection( connId: 1, agentConnId: "abc", - peerChatVRange: VersionRange(minVersion: 1, maxVersion: 1), + peerChatVRange: VersionRange(1, 1), connStatus: .ready, connLevel: 0, viaGroupLink: false, @@ -1690,17 +1696,13 @@ public struct Connection: Decodable, Hashable { } public struct VersionRange: Decodable, Hashable { - public init(minVersion: Int, maxVersion: Int) { + public init(_ minVersion: Int, _ maxVersion: Int) { self.minVersion = minVersion self.maxVersion = maxVersion } public var minVersion: Int public var maxVersion: Int - - public func isCompatibleRange(_ vRange: VersionRange) -> Bool { - self.minVersion <= vRange.maxVersion && vRange.minVersion <= self.maxVersion - } } public struct SecurityCode: Decodable, Equatable, Hashable { @@ -1752,7 +1754,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { public static let sampleData = UserContactRequest( contactRequestId: 1, userContactLinkId: 1, - cReqChatVRange: VersionRange(minVersion: 1, maxVersion: 1), + cReqChatVRange: VersionRange(1, 1), localDisplayName: "alice", profile: Profile.sampleData, createdAt: .now, @@ -1989,6 +1991,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var memberContactId: Int64? public var memberContactProfileId: Int64 public var activeConn: Connection? + public var memberChatVRange: VersionRange public var id: String { "#\(groupId) @\(groupMemberId)" } public var displayName: String { @@ -2083,7 +2086,19 @@ public struct GroupMember: Identifiable, Decodable, Hashable { return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .admin && userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive } + + public var canReceiveReports: Bool { + memberRole >= .moderator && versionRange.maxVersion >= REPORTS_VERSION + } + public var versionRange: VersionRange { + if let activeConn { + activeConn.peerChatVRange + } else { + memberChatVRange + } + } + public var memberIncognito: Bool { memberProfile.profileId != memberContactProfileId } @@ -2102,7 +2117,8 @@ public struct GroupMember: Identifiable, Decodable, Hashable { memberProfile: LocalProfile.sampleData, memberContactId: 1, memberContactProfileId: 1, - activeConn: Connection.sampleData + activeConn: Connection.sampleData, + memberChatVRange: VersionRange(2, 12) ) } @@ -2121,19 +2137,23 @@ public struct GroupMemberIds: Decodable, Hashable { } public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable { - case observer = "observer" - case author = "author" - case member = "member" - case admin = "admin" - case owner = "owner" + case observer + case author + case member + case moderator + case admin + case owner public var id: Self { self } + public static var supportedRoles: [GroupMemberRole] = [.observer, .member, .admin, .owner] + public var text: String { switch self { case .observer: return NSLocalizedString("observer", comment: "member role") case .author: return NSLocalizedString("author", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") + case .moderator: return NSLocalizedString("moderator", comment: "member role") case .admin: return NSLocalizedString("admin", comment: "member role") case .owner: return NSLocalizedString("owner", comment: "member role") } @@ -2141,11 +2161,12 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod private var comparisonValue: Int { switch self { - case .observer: return 0 - case .author: return 1 - case .member: return 2 - case .admin: return 3 - case .owner: return 4 + case .observer: 0 + case .author: 1 + case .member: 2 + case .moderator: 3 + case .admin: 4 + case .owner: 5 } } @@ -2551,6 +2572,17 @@ public struct ChatItem: Identifiable, Decodable, Hashable { default: return true } } + + public var isReport: Bool { + switch content { + case let .sndMsgContent(msgContent), let .rcvMsgContent(msgContent): + switch msgContent { + case .report: true + default: false + } + default: false + } + } public var canBeDeletedForSelf: Bool { (content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete @@ -2636,6 +2668,34 @@ public struct ChatItem: Identifiable, Decodable, Hashable { file: nil ) } + + public static func getReportSample(text: String, reason: ReportReason, item: ChatItem, sender: GroupMember? = nil) -> ChatItem { + let chatDir = if let sender = sender { + CIDirection.groupRcv(groupMember: sender) + } else { + CIDirection.groupSnd + } + + return ChatItem( + chatDir: chatDir, + meta: CIMeta( + itemId: -2, + itemTs: .now, + itemText: "", + itemStatus: .rcvRead, + createdAt: .now, + updatedAt: .now, + itemDeleted: nil, + itemEdited: false, + itemLive: false, + deletable: false, + editable: false + ), + content: .sndMsgContent(msgContent: .report(text: text, reason: reason)), + quotedItem: CIQuote.getSample(item.id, item.meta.createdAt, item.text, chatDir: item.chatDir), + file: nil + ) + } public static func deletedItemDummy() -> ChatItem { ChatItem( @@ -3250,14 +3310,12 @@ public struct CIQuote: Decodable, ItemContent, Hashable { public var sentAt: Date public var content: MsgContent public var formattedText: [FormattedText]? - public var text: String { switch (content.text, content) { case let ("", .voice(_, duration)): return durationText(duration) default: return content.text } } - public func getSender(_ membership: GroupMember?) -> String? { switch (chatDir) { case .directSnd: return "you" @@ -3279,6 +3337,17 @@ public struct CIQuote: Decodable, ItemContent, Hashable { } return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) } + + public func memberToModerate(_ chatInfo: ChatInfo) -> GroupMember? { + switch (chatInfo, chatDir) { + case let (.group(groupInfo), .groupRcv(groupMember)): + let m = groupInfo.membership + return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole + ? groupMember + : nil + default: return nil + } + } } public struct CIReactionCount: Decodable, Hashable { @@ -3620,6 +3689,7 @@ public enum MsgContent: Equatable, Hashable { case video(text: String, image: String, duration: Int) case voice(text: String, duration: Int) case file(String) + case report(text: String, reason: ReportReason) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) @@ -3631,6 +3701,7 @@ public enum MsgContent: Equatable, Hashable { case let .video(text, _, _): return text case let .voice(text, _): return text case let .file(text): return text + case let .report(text, _): return text case let .unknown(_, text): return text } } @@ -3690,6 +3761,7 @@ public enum MsgContent: Equatable, Hashable { case preview case image case duration + case reason } public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool { @@ -3700,6 +3772,7 @@ public enum MsgContent: Equatable, Hashable { case let (.video(lt, li, ld), .video(rt, ri, rd)): return lt == rt && li == ri && ld == rd case let (.voice(lt, ld), .voice(rt, rd)): return lt == rt && ld == rd case let (.file(lf), .file(rf)): return lf == rf + case let (.report(lt, lr), .report(rt, rr)): return lt == rt && lr == rr case let (.unknown(lType, lt), .unknown(rType, rt)): return lType == rType && lt == rt default: return false } @@ -3735,6 +3808,10 @@ extension MsgContent: Decodable { case "file": let text = try container.decode(String.self, forKey: CodingKeys.text) self = .file(text) + case "report": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let reason = try container.decode(ReportReason.self, forKey: CodingKeys.reason) + self = .report(text: text, reason: reason) default: let text = try? container.decode(String.self, forKey: CodingKeys.text) self = .unknown(type: type, text: text ?? "unknown message format") @@ -3772,6 +3849,10 @@ extension MsgContent: Encodable { case let .file(text): try container.encode("file", forKey: .type) try container.encode(text, forKey: .text) + case let .report(text, reason): + try container.encode("report", forKey: .type) + try container.encode(text, forKey: .text) + try container.encode(reason, forKey: .reason) // TODO use original JSON and type case let .unknown(_, text): try container.encode("text", forKey: .type) @@ -3851,6 +3932,57 @@ public enum FormatColor: String, Decodable, Hashable { } } +public enum ReportReason: Hashable { + case spam + case illegal + case community + case profile + case other + case unknown(type: String) + + public static var supportedReasons: [ReportReason] = [.spam, .illegal, .community, .profile, .other] + + public var text: String { + switch self { + case .spam: return NSLocalizedString("Spam", comment: "report reason") + case .illegal: return NSLocalizedString("Inappropriate content", comment: "report reason") + case .community: return NSLocalizedString("Community guidelines violation", comment: "report reason") + case .profile: return NSLocalizedString("Inappropriate profile", comment: "report reason") + case .other: return NSLocalizedString("Another reason", comment: "report reason") + case let .unknown(type): return type + } + } +} + +extension ReportReason: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .spam: try container.encode("spam") + case .illegal: try container.encode("illegal") + case .community: try container.encode("community") + case .profile: try container.encode("profile") + case .other: try container.encode("other") + case let .unknown(type): try container.encode(type) + } + } +} + +extension ReportReason: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let type = try container.decode(String.self) + switch type { + case "spam": self = .spam + case "illegal": self = .illegal + case "community": self = .community + case "profile": self = .profile + case "other": self = .other + default: self = .unknown(type: type) + } + } +} + // Struct to use with simplex API public struct LinkPreview: Codable, Equatable, Hashable { public init(uri: URL, title: String, description: String = "", image: String) { 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 ac33917588..2ff5bb157d 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 @@ -1383,11 +1383,7 @@ data class Connection( } @Serializable -data class VersionRange(val minVersion: Int, val maxVersion: Int) { - - fun isCompatibleRange(vRange: VersionRange): Boolean = - this.minVersion <= vRange.maxVersion && vRange.minVersion <= this.maxVersion -} +data class VersionRange(val minVersion: Int, val maxVersion: Int) @Serializable data class SecurityCode(val securityCode: String, val verifiedAt: Instant) 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 e78472d2d4..0594bd9711 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 @@ -46,11 +46,8 @@ import java.util.Date typealias ChatCtrl = Long -// currentChatVersion in core -const val CURRENT_CHAT_VERSION: Int = 2 - // version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core) -val CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion = 2, maxVersion = CURRENT_CHAT_VERSION) +val CREATE_MEMBER_CONTACT_VERSION = 2 enum class CallOnLockScreen { DISABLE, From 7281255480a2f94751ad7b7b114278f7606bc214 Mon Sep 17 00:00:00 2001 From: Diogo Date: Wed, 8 Jan 2025 20:07:32 +0000 Subject: [PATCH 15/23] android, desktop: inline reports (#5485) * simple send and receive * fix sending reason enum via api * trim "" * report preview and msg display * adding support for moderator (not active) * disable all bulk actions for reports * progress on context menu * make delete messages and block fn suspend * block and moderate * fixes and code cleanup * never show report on own messages * minor code improvements * supportedRoles -> selectableRoles * remove paddings on msg not allowed and other overlapping views, change color * reports: disables attachments, cleans previews and stops lives * disable report on lives * refactor * reports - enable delete for self on bulk actions * text * select report context menu * ios: text --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../chat/simplex/common/model/ChatModel.kt | 40 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 2 +- .../simplex/common/views/chat/ChatView.kt | 72 ++-- .../simplex/common/views/chat/ComposeView.kt | 74 ++++- .../common/views/chat/ContextItemView.kt | 7 +- .../views/chat/SelectableChatItemToolbars.kt | 8 +- .../simplex/common/views/chat/SendMsgView.kt | 5 +- .../views/chat/group/AddGroupMembersView.kt | 4 +- .../views/chat/group/GroupMemberInfoView.kt | 16 +- .../common/views/chat/item/ChatItemView.kt | 312 ++++++++++++++++-- .../common/views/chat/item/FramedItemView.kt | 28 +- .../views/chat/item/MarkedDeletedItemView.kt | 11 +- .../common/views/chat/item/TextItemView.kt | 5 +- .../common/views/chatlist/ChatPreviewView.kt | 14 +- .../commonMain/resources/MR/base/strings.xml | 28 ++ 16 files changed, 534 insertions(+), 94 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 285bdf2d98..b74cbfbc81 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1706,7 +1706,7 @@ struct ChatView: View { AlertManager.shared.showAlert( Alert( title: Text("Archive report?"), - message: Text("The report will be archived for all moderators and reporter."), + message: Text("The report will be archived for you."), primaryButton: .destructive(Text("Archive")) { deletingItem = cItem deleteMessage(.cidmInternalMark, moderate: false) 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 2ff5bb157d..5b05d033c8 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 @@ -1638,7 +1638,7 @@ data class GroupMember ( fun canChangeRoleTo(groupInfo: GroupInfo): List? = if (!canBeRemoved(groupInfo)) null else groupInfo.membership.memberRole.let { userRole -> - GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Author } + GroupMemberRole.selectableRoles.filter { it <= userRole } } fun canBlockForAll(groupInfo: GroupInfo): Boolean { @@ -1689,13 +1689,19 @@ enum class GroupMemberRole(val memberRole: String) { @SerialName("observer") Observer("observer"), // order matters in comparisons @SerialName("author") Author("author"), @SerialName("member") Member("member"), + @SerialName("moderator") Moderator("moderator"), @SerialName("admin") Admin("admin"), @SerialName("owner") Owner("owner"); + companion object { + val selectableRoles: List = listOf(Observer, Member, Admin, Owner) + } + val text: String get() = when (this) { Observer -> generalGetString(MR.strings.group_member_role_observer) Author -> generalGetString(MR.strings.group_member_role_author) Member -> generalGetString(MR.strings.group_member_role_member) + Moderator -> generalGetString(MR.strings.group_member_role_moderator) Admin -> generalGetString(MR.strings.group_member_role_admin) Owner -> generalGetString(MR.strings.group_member_role_owner) } @@ -2116,6 +2122,12 @@ data class ChatItem ( else -> true } + val isReport: Boolean get() = when (content) { + is CIContent.SndMsgContent, is CIContent.RcvMsgContent -> + content.msgContent is MsgContent.MCReport + else -> false + } + val canBeDeletedForSelf: Boolean get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete @@ -2946,6 +2958,19 @@ class CIQuote ( null -> null } + fun memberToModerate(chatInfo: ChatInfo): GroupMember? { + return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { + val m = chatInfo.groupInfo.membership + if (m.memberRole >= GroupMemberRole.Moderator && m.memberRole >= chatDir.groupMember.memberRole) { + chatDir.groupMember + } else { + null + } + } else { + null + } + } + companion object { fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote = CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text)) @@ -3589,6 +3614,19 @@ sealed class ReportReason { @Serializable @SerialName("profile") object Profile: ReportReason() @Serializable @SerialName("other") object Other: ReportReason() @Serializable @SerialName("unknown") data class Unknown(val type: String): ReportReason() + + companion object { + val supportedReasons: List = listOf(Spam, Illegal, Community, Profile, Other) + } + + val text: String get() = when (this) { + Spam -> generalGetString(MR.strings.report_reason_spam) + Illegal -> generalGetString(MR.strings.report_reason_illegal) + Community -> generalGetString(MR.strings.report_reason_community) + Profile -> generalGetString(MR.strings.report_reason_profile) + Other -> generalGetString(MR.strings.report_reason_other) + is Unknown -> type + } } object ReportReasonSerializer : KSerializer { 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 0594bd9711..6400d2d2d9 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 @@ -3330,7 +3330,7 @@ sealed class CC { val msgs = json.encodeToString(composedMessages) "/_create *$noteFolderId json $msgs" } - is ApiReportMessage -> "/_report #$groupId $chatItemId reason=$reportReason $reportText" + is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" 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 3c0f1f7769..5ab6906558 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 @@ -301,41 +301,41 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } }, deleteMessage = { itemId, mode -> - withBGApi { - val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } - val toModerate = toDeleteItem?.memberToModerate(chatInfo) - val groupInfo = toModerate?.first - val groupMember = toModerate?.second - val deletedChatItem: ChatItem? - val toChatItem: ChatItem? - val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { - chatModel.controller.apiDeleteMemberChatItems( - chatRh, - groupId = groupInfo.groupId, - itemIds = listOf(itemId) - ) - } else { - chatModel.controller.apiDeleteChatItems( - chatRh, - type = chatInfo.chatType, - id = chatInfo.apiId, - itemIds = listOf(itemId), - mode = mode - ) - } - val deleted = r?.firstOrNull() - if (deleted != null) { - deletedChatItem = deleted.deletedChatItem.chatItem - toChatItem = deleted.toChatItem?.chatItem - withChats { - if (toChatItem != null) { - upsertChatItem(chatRh, chatInfo, toChatItem) - } else { - removeChatItem(chatRh, chatInfo, deletedChatItem) - } + val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } + val toModerate = toDeleteItem?.memberToModerate(chatInfo) + val groupInfo = toModerate?.first + val groupMember = toModerate?.second + val deletedChatItem: ChatItem? + val toChatItem: ChatItem? + val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { + chatModel.controller.apiDeleteMemberChatItems( + chatRh, + groupId = groupInfo.groupId, + itemIds = listOf(itemId) + ) + } else { + chatModel.controller.apiDeleteChatItems( + chatRh, + type = chatInfo.chatType, + id = chatInfo.apiId, + itemIds = listOf(itemId), + mode = mode + ) + } + val deleted = r?.firstOrNull() + if (deleted != null) { + deletedChatItem = deleted.deletedChatItem.chatItem + toChatItem = deleted.toChatItem?.chatItem + withChats { + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, deletedChatItem) } } } + + deleted }, deleteMessages = { itemIds -> deleteMessages(chatRh, chatInfo, itemIds, false, moderate = false) }, receiveFile = { fileId -> @@ -599,7 +599,7 @@ fun ChatLayout( info: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -946,7 +946,7 @@ fun BoxScope.ChatItemsList( showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit, loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -2434,7 +2434,7 @@ fun PreviewChatLayout() { info = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _, _ -> }, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, @@ -2507,7 +2507,7 @@ fun PreviewGroupChatLayout() { info = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _, _ -> }, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = {}, receiveFile = { _ -> }, cancelFile = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 2247a615df..01c6faa573 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.onSizeChanged @@ -51,6 +52,7 @@ sealed class ComposeContextItem { @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class ForwardingItems(val chatItems: List, val fromChatInfo: ChatInfo): ComposeContextItem() + @Serializable class ReportedItem(val chatItem: ChatItem, val reason: ReportReason): ComposeContextItem() } @Serializable @@ -89,13 +91,28 @@ data class ComposeState( is ComposeContextItem.ForwardingItems -> true else -> false } + val reporting: Boolean + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> true + else -> false + } + val submittingValidReport: Boolean + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> { + when (contextItem.reason) { + is ReportReason.Other -> message.isNotEmpty() + else -> true + } + } + else -> false + } val sendEnabled: () -> Boolean get() = { val hasContent = when (preview) { is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() || forwarding || liveMessage != null + else -> message.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport } hasContent && !inProgress } @@ -119,7 +136,7 @@ data class ComposeState( val attachmentDisabled: Boolean get() { - if (editing || forwarding || liveMessage != null || inProgress) return true + if (editing || forwarding || liveMessage != null || inProgress || reporting) return true return when (preview) { ComposePreview.NoPreview -> false is ComposePreview.CLinkPreview -> false @@ -136,6 +153,12 @@ data class ComposeState( is ComposePreview.FilePreview -> true } + val placeholder: String + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> contextItem.reason.text + else -> generalGetString(MR.strings.compose_message_placeholder) + } + val empty: Boolean get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem @@ -489,6 +512,19 @@ fun ComposeView( } } + suspend fun sendReport(reportReason: ReportReason, chatItemId: Long): List? { + val cItems = chatModel.controller.apiReportMessage(chat.remoteHostId, chat.chatInfo.apiId, chatItemId, reportReason, msgText) + if (cItems != null) { + withChats { + cItems.forEach { chatItem -> + addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) + } + } + } + + return cItems?.map { it.chatItem } + } + suspend fun sendMemberContactInvitation() { val mc = checkLinkPreview() val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) @@ -554,6 +590,8 @@ fun ComposeView( } else if (liveMessage != null && liveMessage.sent) { val updatedMessage = updateMessage(liveMessage.chatItem, chat, live) sent = if (updatedMessage != null) listOf(updatedMessage) else null + } else if (cs.contextItem is ComposeContextItem.ReportedItem) { + sent = sendReport(cs.contextItem.reason, cs.contextItem.chatItem.id) } else { val msgs: ArrayList = ArrayList() val files: ArrayList = ArrayList() @@ -835,14 +873,33 @@ fun ComposeView( @Composable fun MsgNotAllowedView(reason: String, icon: Painter) { - val color = MaterialTheme.appColors.receivedMessage - Row(Modifier.padding(top = 5.dp).fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { + val color = MaterialTheme.appColors.receivedQuote + Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { Icon(icon, null, tint = MaterialTheme.colors.secondary) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) Text(reason, fontStyle = FontStyle.Italic) } } + @Composable + fun ReportReasonView(reason: ReportReason) { + val reportText = when (reason) { + is ReportReason.Spam -> generalGetString(MR.strings.report_compose_reason_header_spam) + is ReportReason.Illegal -> generalGetString(MR.strings.report_compose_reason_header_illegal) + is ReportReason.Profile -> generalGetString(MR.strings.report_compose_reason_header_profile) + is ReportReason.Community -> generalGetString(MR.strings.report_compose_reason_header_community) + is ReportReason.Other -> generalGetString(MR.strings.report_compose_reason_header_other) + is ReportReason.Unknown -> null // should never happen + } + + if (reportText != null) { + val color = MaterialTheme.appColors.receivedQuote + Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { + Text(reportText, fontStyle = FontStyle.Italic, fontSize = 12.sp) + } + } + } + @Composable fun contextItemView() { when (val contextItem = composeState.value.contextItem) { @@ -856,6 +913,9 @@ fun ComposeView( is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } + is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatType = chat.chatInfo.chatType, contextIconColor = Color.Red) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) + } } } @@ -893,6 +953,10 @@ fun ComposeView( if (nextSendGrpInv.value) { ComposeContextInvitingContactMemberView() } + val ctx = composeState.value.contextItem + if (ctx is ComposeContextItem.ReportedItem) { + ReportReasonView(ctx.reason) + } val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks) val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files) val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice) @@ -1050,7 +1114,7 @@ fun ComposeView( sendButtonColor = sendButtonColor, timedMessageAllowed = timedMessageAllowed, customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, - placeholder = stringResource(MR.strings.compose_message_placeholder), + placeholder = composeState.value.placeholder, sendMessage = { ttl -> sendMessage(ttl) resetLinkPreview() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index 5850f0b7ec..1657a1f0b7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -12,6 +12,7 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp @@ -31,6 +32,7 @@ fun ContextItemView( contextIcon: Painter, showSender: Boolean = true, chatType: ChatType, + contextIconColor: Color = MaterialTheme.colors.secondary, cancelContextItem: () -> Unit, ) { val sentColor = MaterialTheme.appColors.sentMessage @@ -85,7 +87,6 @@ fun ContextItemView( Row( Modifier - .padding(top = 8.dp) .background(if (sent) sentColor else receivedColor), verticalAlignment = Alignment.CenterVertically ) { @@ -103,8 +104,8 @@ fun ContextItemView( .height(20.dp) .width(20.dp), contentDescription = stringResource(MR.strings.icon_descr_context), - tint = MaterialTheme.colors.secondary, - ) + tint = contextIconColor, + ) if (contextItems.count() == 1) { val contextItem = contextItems[0] diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 838398c503..582a981443 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -138,10 +138,10 @@ private fun recheckItems(chatInfo: ChatInfo, for (ci in chatItems) { if (selected.contains(ci.id)) { rDeleteEnabled = rDeleteEnabled && ci.canBeDeletedForSelf - rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote - rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd - rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null - rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy + rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote && !ci.isReport + rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd && !ci.isReport + rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null && !ci.isReport + rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy && !ci.isReport rSelectedChatItems.add(ci.id) // we are collecting new selected items here to account for any changes in chat items list } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index d912f8e030..e2b44478af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -74,7 +74,7 @@ fun SendMsgView( } } val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && - !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) + !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || @@ -125,6 +125,9 @@ fun SendMsgView( } when { progressByTimeout -> ProgressIndicator() + cs.contextItem is ComposeContextItem.ReportedItem -> { + SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) + } showVoiceButton && sendMsgEnabled -> { Row(verticalAlignment = Alignment.CenterVertically) { val stopRecOnNextClick = remember { mutableStateOf(false) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 25661f00a0..c8b61e4179 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -209,8 +209,8 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState Unit = { withBGApi { blockMemberForAll(rhId, gInfo, mem, true) } }) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.block_for_all_question), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_for_all), onConfirm = { - blockMemberForAll(rhId, gInfo, mem, true) + blockMember() }, destructive = true, ) @@ -765,17 +765,15 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_for_all), onConfirm = { - blockMemberForAll(rhId, gInfo, mem, false) + withBGApi { blockMemberForAll(rhId, gInfo, mem, false) } }, ) } -fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { - withBGApi { - val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) - withChats { - upsertGroupMember(rhId, gInfo, updatedMember) - } +suspend fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { + val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) + withChats { + upsertGroupMember(rhId, gInfo, updatedMember) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 647c74da06..1a094f613a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.chat.item +import SectionItemView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.interaction.HoverInteraction @@ -20,14 +21,18 @@ import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.currentUser +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.group.blockForAllAlert +import chat.simplex.common.views.chat.group.blockMemberForAll import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -72,7 +77,7 @@ fun ChatItemView( selectedChatItems: MutableState?>, fillMaxWidth: Boolean = true, selectChatItem: () -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -108,6 +113,12 @@ fun ChatItemView( val onLinkLongClick = { _: String -> showMenu.value = true } val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value + val deleteMessageAsync: (Long, CIDeleteMode) -> Unit = { id, mode -> + withBGApi { + deleteMessage(id, mode) + } + } + Box( modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, contentAlignment = alignment, @@ -282,7 +293,7 @@ fun ChatItemView( @Composable fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -295,7 +306,36 @@ fun ChatItemView( val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) when { // cItem.id check is a special case for live message chat item which has negative ID while not sent yet - cItem.content.msgContent != null && cItem.id >= 0 -> { + cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { + DefaultDropdownMenu(showMenu) { + if (cItem.chatDir is CIDirection.GroupSnd) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + } else { + ArchiveReportItemAction(cItem, showMenu, deleteMessageAsync) + val qItem = cItem.quotedItem + if (qItem != null) { + ModerateReportItemAction(rhId, cInfo, cItem, qItem, showMenu, deleteMessage) + val rMember = qItem.memberToModerate(cInfo) + if (rMember != null && !rMember.blockedByAdmin && rMember.canBlockForAll(cInfo.groupInfo)) { + BlockMemberAction( + rhId, + chatInfo = cInfo, + groupInfo = cInfo.groupInfo, + cItem = cItem, + reportedItem = qItem, + member = rMember, + showMenu = showMenu, + deleteMessage = deleteMessage + ) + } + } + + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { DefaultDropdownMenu(showMenu) { if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { MsgReactionsMenu() @@ -381,11 +421,15 @@ fun ChatItemView( CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) } - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null && cItem.chatDir !is CIDirection.GroupSnd) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + if (cItem.chatDir !is CIDirection.GroupSnd) { + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessageAsync) + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole < GroupMemberRole.Moderator && !live) { + ReportItemAction(cItem, composeState, showMenu) + } } if (cItem.canBeDeletedForSelf) { Divider() @@ -403,7 +447,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -413,7 +457,7 @@ fun ChatItemView( cItem.isDeletedContent -> { DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -427,7 +471,7 @@ fun ChatItemView( } else { ExpandItemAction(revealed, showMenu, reveal) } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -436,7 +480,7 @@ fun ChatItemView( } else -> { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (selectedChatItems.value == null) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -453,7 +497,7 @@ fun ChatItemView( RevealItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -487,7 +531,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -544,7 +588,7 @@ fun ChatItemView( MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -778,7 +822,7 @@ fun ModerateItemAction( painterResource(MR.images.ic_flag), onClick = { showMenu.value = false - moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + moderateMessageAlertDialog(cItem.id, questionText, deleteMessage = deleteMessage) }, color = Color.Red ) @@ -847,6 +891,183 @@ private fun ShrinkItemAction(revealed: State, showMenu: MutableState, + showMenu: MutableState, +) { + ItemAction( + stringResource(MR.strings.report_verb), + painterResource(MR.images.ic_flag), + onClick = { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.report_reason_alert_title), + buttons = { + ReportReason.supportedReasons.forEach { reason -> + SectionItemView({ + if (composeState.value.editing) { + composeState.value = ComposeState( + contextItem = ComposeContextItem.ReportedItem(cItem, reason), + useLinkPreviews = false, + preview = ComposePreview.NoPreview, + ) + } else { + composeState.value = composeState.value.copy( + contextItem = ComposeContextItem.ReportedItem(cItem, reason), + useLinkPreviews = false, + preview = ComposePreview.NoPreview, + ) + } + AlertManager.shared.hideAlert() + }) { + Text(reason.text, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + ) + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun ModerateReportItemAction( + rhId: Long?, + chatInfo: ChatInfo, + cItem: ChatItem, + reportedItem: CIQuote, + showMenu: MutableState, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? +) { + ItemAction( + stringResource(MR.strings.moderate_verb), + painterResource(MR.images.ic_flag), + onClick = { + withBGApi { + val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) + if (reportedMessageId != null) { + moderateMessageAlertDialog( + reportedMessageId, + questionText = moderateMessageQuestionText(chatInfo.featureEnabled(ChatFeature.FullDelete), 1), + deleteMessage = { id, m -> + withApi { + val deleted = deleteMessage(id, m) + if (deleted != null) { + deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) + } + } + }, + ) + } + } + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun BlockMemberAction( + rhId: Long?, + chatInfo: ChatInfo, + groupInfo: GroupInfo, + cItem: ChatItem, + reportedItem: CIQuote, + member: GroupMember, + showMenu: MutableState, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? +) { + ItemAction( + stringResource(MR.strings.block_member_button), + painterResource(MR.images.ic_back_hand), + onClick = { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.report_block_and_moderate_title), + buttons = { + SectionItemView({ + AlertManager.shared.hideAlert() + withBGApi { + val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) + if (reportedMessageId != null) { + blockAndModerateAlertDialog( + rhId, + reportedMessageId = reportedMessageId, + reportId = cItem.id, + gInfo = groupInfo, + mem = member, + deleteMessage = deleteMessage, + ) + } + } + }) { + Text(generalGetString(MR.strings.report_block_and_moderate_block_and_moderate_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + SectionItemView({ + AlertManager.shared.hideAlert() + withBGApi { + val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) + if (reportedMessageId != null) { + blockForAllAlert(rhId, gInfo = groupInfo, mem = member, blockMember = { + withBGApi { + try { + blockMemberForAll( + rhId, + gInfo = groupInfo, + member = member, + blocked = true + ) + deleteMessage(reportedMessageId, CIDeleteMode.cidmInternalMark) + } catch (ex: Exception) { + Log.e(TAG, "BlockMemberAction block and moderate ${ex.message}") + } + } + }) + } + } + }) { + Text(generalGetString(MR.strings.report_block_and_moderate_only_block_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + ) + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState, deleteMessage: (Long, CIDeleteMode) -> Unit) { + ItemAction( + stringResource(MR.strings.archive_verb), + painterResource(MR.images.ic_inventory_2), + onClick = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_archive_alert_title), + text = generalGetString(MR.strings.report_archive_alert_desc), + onConfirm = { + deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) + }, + destructive = true, + confirmText = generalGetString(MR.strings.archive_verb), + ) + showMenu.value = false + }, + color = Color.Red + ) +} + @Composable fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, onClick: () -> Unit) { val finalColor = if (color == Color.Unspecified) { @@ -1133,7 +1354,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) AlertManager.shared.hideAlert() }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } - if (chatItem.meta.deletable && !chatItem.localNote) { + if (chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport) { Spacer(Modifier.padding(horizontal = 4.dp)) TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) @@ -1180,14 +1401,14 @@ fun moderateMessageQuestionText(fullDeleteAllowed: Boolean, count: Int): String } } -fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { +fun moderateMessageAlertDialog(chatItemId: Long, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_member_message__question), text = questionText, confirmText = generalGetString(MR.strings.delete_verb), destructive = true, onConfirm = { - deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) + deleteMessage(chatItemId, CIDeleteMode.cidmBroadcast) } ) } @@ -1202,8 +1423,59 @@ fun moderateMessagesAlertDialog(itemIds: List, questionText: String, delet ) } +private fun blockAndModerateAlertDialog( + rhId: Long?, + reportedMessageId: Long, + reportId: Long, + gInfo: GroupInfo, + mem: GroupMember, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? +) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_block_and_moderate_confirmation_title), + text = generalGetString( + if (gInfo.fullGroupPreferences.fullDelete.on) MR.strings.report_block_and_moderate_confirmation_desc_full_delete else MR.strings.report_block_and_moderate_confirmation_desc_full_delete).format(mem.chatViewName), + confirmText = generalGetString(MR.strings.report_block_and_moderate_confirmation_ok), + onConfirm = { + withBGApi { + try { + val deleted = deleteMessage(reportedMessageId, CIDeleteMode.cidmBroadcast) + if (deleted != null) { + blockMemberForAll(rhId, gInfo, mem, true) + deleteMessage(reportId, CIDeleteMode.cidmInternalMark) + } + } catch (ex: Exception) { + Log.e(TAG, "blockAndModerateAlertDialog block and moderate ${ex.message}") + } + } + }, + destructive = true, + ) +} + expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) +private suspend fun getLocalIdForReportedMessage( + rhId: Long?, + chatInfo: ChatInfo, + reportedItem: CIQuote, + itemId: Long): Long? { + if (reportedItem.itemId != null) { + return reportedItem.itemId + } + val item = apiLoadSingleMessage(rhId, chatInfo.chatType, chatInfo.apiId, itemId) + + if (item?.quotedItem?.itemId != null) { + withChats { + updateChatItem(chatInfo, item) + } + return item.quotedItem.itemId + } else { + showQuotedItemDoesNotExistAlert() + return null + } +} + @Preview @Composable fun PreviewChatItemView( @@ -1221,7 +1493,7 @@ fun PreviewChatItemView( range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, @@ -1267,7 +1539,7 @@ fun PreviewChatItemViewDeletedContent() { range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index e955428031..2827a649b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -88,7 +88,7 @@ fun FramedItemView( } @Composable - fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false) { + fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) { val sentColor = MaterialTheme.appColors.sentQuote val receivedColor = MaterialTheme.appColors.receivedQuote Row( @@ -104,7 +104,7 @@ fun FramedItemView( icon, caption, Modifier.size(18.dp), - tint = if (isInDarkTheme()) FileDark else FileLight + tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight ) } Text( @@ -216,7 +216,18 @@ fun FramedItemView( .padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) ) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { - if (ci.meta.itemDeleted != null) { + if (ci.isReport) { + if (ci.meta.itemDeleted == null) { + FramedItemHeader( + stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), + true, + painterResource(MR.images.ic_flag), + iconColor = Color.Red + ) + } else { + FramedItemHeader(stringResource(MR.strings.report_item_archived), true, painterResource(MR.images.ic_flag)) + } + } else if (ci.meta.itemDeleted != null) { when (ci.meta.itemDeleted) { is CIDeleted.Moderated -> { FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) @@ -288,6 +299,14 @@ fun FramedItemView( CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } + is MsgContent.MCReport -> { + val prefix = buildAnnotatedString { + withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { + append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") + } + } + CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) + } else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } @@ -315,13 +334,14 @@ fun CIMarkdownText( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean, showTimestamp: Boolean, + prefix: AnnotatedString? = null ) { Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, - uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index d2e19a37d6..410372fe96 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -67,7 +67,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State } val total = moderated + blocked + blockedByAdmin + deleted if (total <= 1) - markedDeletedText(chatItem.meta) + markedDeletedText(chatItem) else if (total == moderated) stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", ")) else if (total == blockedByAdmin) @@ -77,7 +77,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State else stringResource(MR.strings.marked_deleted_items_description).format(total) } else { - markedDeletedText(chatItem.meta) + markedDeletedText(chatItem) } Text( @@ -91,10 +91,11 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State ) } -fun markedDeletedText(meta: CIMeta): String = - when (meta.itemDeleted) { +fun markedDeletedText(cItem: ChatItem): String = + if (cItem.meta.itemDeleted != null && cItem.isReport) generalGetString(MR.strings.report_item_archived) + else when (cItem.meta.itemDeleted) { is CIDeleted.Moderated -> - String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName) + String.format(generalGetString(MR.strings.moderated_item_description), cItem.meta.itemDeleted.byGroupMember.displayName) is CIDeleted.Blocked -> generalGetString(MR.strings.blocked_item_description) is CIDeleted.BlockedByAdmin -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 434cde608a..257ede7d4a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -71,7 +71,8 @@ fun MarkdownText ( inlineContent: Pair Unit, Map>? = null, onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean = false, - showTimestamp: Boolean = true + showTimestamp: Boolean = true, + prefix: AnnotatedString? = null ) { val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr @@ -123,6 +124,7 @@ fun MarkdownText ( val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) + if (prefix != null) append(prefix) if (text is String) append(text) else if (text is AnnotatedString) append(text) if (meta?.isLive == true) { @@ -136,6 +138,7 @@ fun MarkdownText ( val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) + if (prefix != null) append(prefix) for ((i, ft) in formattedText.withIndex()) { if (ft.format == null) append(ft.text) else if (toggleSecrets && ft.format is Format.Secret) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 0e0c3e74f4..d0e9b003e2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.* import chat.simplex.common.ui.theme.* @@ -174,13 +175,23 @@ fun ChatPreviewView( val (text: CharSequence, inlineTextContent) = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) } ci.meta.itemDeleted == null -> ci.text to null - else -> markedDeletedText(ci.meta) to null + else -> markedDeletedText(ci) to null } val formattedText = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> null ci.meta.itemDeleted == null -> ci.formattedText else -> null } + val prefix = when (val mc = ci.content.msgContent) { + is MsgContent.MCReport -> + buildAnnotatedString { + withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { + append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") + } + } + else -> null + } + MarkdownText( text, formattedText, @@ -202,6 +213,7 @@ fun ChatPreviewView( ), inlineContent = inlineTextContent, modifier = Modifier.fillMaxWidth(), + prefix = prefix ) } } else { 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 22ad02a52e..860975a414 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -37,6 +37,9 @@ %d messages marked deleted moderated by %s %1$d messages moderated by %2$s + Only you and moderators see it + Only sender and moderators see it + archived report blocked blocked by admin %d messages blocked @@ -94,6 +97,13 @@ Via browser Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. + + Spam + Inappropriate content + Community guidelines violation + Inappropriate profile + Another reason + Error saving SMP servers Error saving XFTP servers @@ -292,6 +302,16 @@ Most likely this contact has deleted the connection with you. No message This message was deleted or not received yet. + Report reason? + Archive report? + The report will be archived for you. + Block and moderate? + Block and moderate + Only block + Delete member message and block? + The message will be deleted for all members.\nAll new messages from %1$s will be hidden! + The message will be marked as moderated for all members.\nAll new messages from %1$s will be hidden! + Delete and block Error: %1$s @@ -317,6 +337,7 @@ Edit Info Search + Archive Sent message Received message History @@ -334,6 +355,7 @@ Hide Allow Moderate + Report Select Expand Delete message? @@ -448,6 +470,11 @@ Please reduce the message size and send again. Please reduce the message size or remove media and send again. You can copy and reduce the message size to send it. + Report spam: only group moderators will see it. + Report member profile: only group moderators will see it. + Report violation: only group moderators will see it. + Report content: only group moderators will see it. + Report other: only group moderators will see it. Image @@ -1529,6 +1556,7 @@ observer author member + moderator admin owner From bcb7c8bd7b101aedcb470e8739d4b220b59464d3 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 8 Jan 2025 22:13:43 +0000 Subject: [PATCH 16/23] core: do not include reports in group history (#5491) --- src/Simplex/Chat/Store/Messages.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 01ae7b7c28..83cac97b18 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -3051,9 +3051,10 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? WHERE i.user_id = ? AND i.group_id = ? AND i.item_content_tag IN (?,?) + AND i.msg_content_tag NOT IN (?) AND i.item_deleted = 0 AND s.group_snd_item_status_id IS NULL ORDER BY i.item_ts DESC, i.chat_item_id DESC LIMIT ? |] - (groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, count) + (groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, MCReport_, count) From 3cfc74e0fd66cf2ddbfb3ea9aad3d6593483504e Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 9 Jan 2025 13:58:41 +0700 Subject: [PATCH 17/23] android, desktop: fixed loading items when one was deleted (#5472) * android, desktop: fixed loading items when one was deleted * optimization * removed comment --- .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 5ab6906558..a488b66c8b 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 @@ -992,6 +992,10 @@ fun BoxScope.ChatItemsList( val loadingMoreItems = remember { mutableStateOf(false) } val animatedScrollingInProgress = remember { mutableStateOf(false) } val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf() } + LaunchedEffect(chatInfo.id, searchValueIsEmpty.value) { + if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) + ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) + } if (!loadingMoreItems.value) { PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> if (loadingMoreItems.value) return@PreloadItems false @@ -1543,7 +1547,7 @@ private fun PreloadItemsBefore( val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) val items = chatModel.chatItems.value - if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining && items.size >= ChatPagination.INITIAL_COUNT) { + if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { lastIndexToLoadFrom = items.lastIndex } if (allowLoad.value && lastIndexToLoadFrom != null) { From cd9eb66ebb1e604633cbb05484810227b985af7c Mon Sep 17 00:00:00 2001 From: Diogo Date: Thu, 9 Jan 2025 22:28:29 +0000 Subject: [PATCH 18/23] ui: remove support for inline moderation (#5495) * android: remove support for inline moderation * ios: emove support for inline moderation * fix prefix on preview for ios * unused * final pass * ios: should not be able to assign moderator * button label --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Views/Chat/ChatView.swift | 205 ++------------- .../Chat/Group/GroupMemberInfoView.swift | 32 +-- .../Views/ChatList/ChatPreviewView.swift | 2 +- apps/ios/SimpleXChat/ChatTypes.swift | 13 +- .../chat/simplex/common/model/ChatModel.kt | 13 - .../simplex/common/views/chat/ChatView.kt | 72 +++--- .../views/chat/group/GroupMemberInfoView.kt | 16 +- .../common/views/chat/item/ChatItemView.kt | 238 ++---------------- .../commonMain/resources/MR/base/strings.xml | 9 +- 9 files changed, 106 insertions(+), 494 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index b74cbfbc81..3444fd0723 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1284,20 +1284,11 @@ struct ChatView: View { @ViewBuilder private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View { - if let groupInfo = chat.chatInfo.groupInfo, ci.isReport, ci.meta.itemDeleted == nil { - if ci.chatDir == .groupSnd { - deleteButton(ci) - } else { + if case let .group(gInfo) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil { + if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator { archiveReportButton(ci) - if let qi = ci.quotedItem { - moderateReportedButton(qi, ci, groupInfo) - if let rMember = qi.memberToModerate(chat.chatInfo) { - if !rMember.blockedByAdmin, rMember.canBlockForAll(groupInfo: groupInfo) { - blockMemberButton(rMember, groupInfo, qi, ci) - } - } - } } + deleteButton(ci, label: "Delete report") } else if let mc = ci.content.msgContent, !ci.isReport, ci.meta.itemDeleted == nil || revealed { if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, availableReactions.count > 0 { @@ -1351,7 +1342,7 @@ struct ChatView: View { if ci.chatDir != .groupSnd { if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { moderateButton(ci, groupInfo) - } else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole < .moderator, !live, composeState.voiceMessageRecordingState == .noRecording { + } else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { reportButton(ci) } } @@ -1627,7 +1618,7 @@ struct ChatView: View { } } - private func deleteButton(_ ci: ChatItem) -> Button { + private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button { Button(role: .destructive) { if !revealed, let currIndex = m.getChatItemIndex(ci), @@ -1649,10 +1640,7 @@ struct ChatView: View { deletingItem = ci } } label: { - Label( - NSLocalizedString("Delete", comment: "chat item action"), - systemImage: "trash" - ) + Label(label, systemImage: "trash") } } @@ -1668,31 +1656,19 @@ struct ChatView: View { private func moderateButton(_ ci: ChatItem, _ groupInfo: GroupInfo) -> Button { Button(role: .destructive) { - showModerateMessageAlert(groupInfo) { - deletingItem = ci - deleteMessage(.cidmBroadcast, moderate: true) - } - } label: { - Label( - NSLocalizedString("Moderate", comment: "chat item action"), - systemImage: "flag" - ) - } - } - - private func moderateReportedButton(_ rItem: CIQuote, _ reportItem: ChatItem, _ groupInfo: GroupInfo) -> Button { - Button(role: .destructive) { - showModerateMessageAlert(groupInfo) { - Task { - let deleted = await deleteReportedMessage(rItem, reportItem.id, groupInfo) - if deleted != nil { - await MainActor.run { - deletingItem = reportItem - deleteMessage(.cidmInternalMark, moderate: false) - } - } - } - } + AlertManager.shared.showAlert(Alert( + title: Text("Delete member message?"), + message: Text( + groupInfo.fullGroupPreferences.fullDelete.on + ? "The message will be deleted for all members." + : "The message will be marked as moderated for all members." + ), + primaryButton: .destructive(Text("Delete")) { + deletingItem = ci + deleteMessage(.cidmBroadcast, moderate: true) + }, + secondaryButton: .cancel() + )) } label: { Label( NSLocalizedString("Moderate", comment: "chat item action"), @@ -1715,74 +1691,7 @@ struct ChatView: View { ) ) } label: { - Label( - NSLocalizedString("Archive", comment: "chat item action"), - systemImage: "archivebox" - ) - } - } - - private func blockMemberButton(_ member: GroupMember, _ groupInfo: GroupInfo, _ rItem: CIQuote, _ report: ChatItem) -> Button { - Button(role: .destructive) { - actionSheet = SomeActionSheet( - actionSheet: ActionSheet( - title: Text("Block and moderate?"), - buttons: [ - .destructive(Text("Block and moderate")) { - AlertManager.shared.showAlert( - Alert( - title: Text("Delete member message and block?"), - message: Text( - NSLocalizedString( - groupInfo.fullGroupPreferences.fullDelete.on - ? "The message will be deleted for all members.\nAll new messages from \(member.chatViewName) will be hidden!" - : "The message will be marked as moderated for all members.\n All new messages from \(member.chatViewName) will be hidden!" - , comment: "block and moderate action" - ) - ), - primaryButton: .destructive(Text("Delete and block")) { - Task { - let deleted = await deleteReportedMessage(rItem, report.id, groupInfo) - if deleted != nil { - let blocked = await blockMemberForAll(groupInfo, member, true) - - if blocked != nil { - await MainActor.run { - deletingItem = report - deleteMessage(.cidmInternalMark, moderate: false) - } - } - } - } - }, - secondaryButton: .cancel() - ) - ) - }, - .destructive(Text("Only block")) { - Task { - if (await getLocalIdForReportedMessage(rItem, report.id, groupInfo)) != nil { - AlertManager.shared.showAlert( - blockForAllAlert(groupInfo, member) { - deletingItem = report - deleteMessage(.cidmInternalMark, moderate: false) - } - ) - } else { - showNoMessageMessageAlert() - } - } - }, - .cancel() - ] - ), - id: "blockMember" - ) - } label: { - Label( - NSLocalizedString("Block member", comment: "chat item action"), - systemImage: "hand.raised" - ) + Label("Archive report", systemImage: "archivebox") } } @@ -1886,60 +1795,6 @@ struct ChatView: View { itemIds.forEach { selectedChatItems?.remove($0) } } } - - private func deleteReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> ChatItemDeletion? { - do { - let itemId = await getLocalIdForReportedMessage(rItem, reportId, groupInfo) - - if let itemId = itemId { - let deletedItem = try await apiDeleteMemberChatItems( - groupId: groupInfo.apiId, - itemIds: [itemId] - ).first - - if let di = deletedItem { - await MainActor.run { - if let toItem = di.toChatItem { - _ = m.upsertChatItem(chat.chatInfo, toItem.chatItem) - } else { - m.removeChatItem(chat.chatInfo, di.deletedChatItem.chatItem) - } - } - - return di - } - } else { - showNoMessageMessageAlert() - } - } catch { - logger.error("ChatView.deleteReportedMessage error: \(error)") - AlertManager.shared.showAlertMsg(title: LocalizedStringKey("Error"), message: LocalizedStringKey("Failed to delete reported message")) - } - - return nil - } - - private func getLocalIdForReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> Int64? { - do { - if let itemId = rItem.itemId { - return itemId - } else { - let reportItem = try await apiGetChatItems( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - pagination: .around(chatItemId: reportId, count: 0) - ).first - - if let itemId = reportItem?.quotedItem?.itemId { - return itemId - } - } - } catch { - logger.error("ChatView.getLocalIdForReportedMessage error: \(error)") - } - - return nil - } private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) { logger.debug("ChatView deleteMessage") @@ -2014,26 +1869,6 @@ struct ChatView: View { } } -private func showModerateMessageAlert(_ groupInfo: GroupInfo, _ onModerate: @escaping () -> Void) { - AlertManager.shared.showAlert(Alert( - title: Text("Delete member message?"), - message: Text( - groupInfo.fullGroupPreferences.fullDelete.on - ? "The message will be deleted for all members." - : "The message will be marked as moderated for all members." - ), - primaryButton: .destructive(Text("Delete"), action: onModerate), - secondaryButton: .cancel() - )) -} - -private func showNoMessageMessageAlert() { - AlertManager.shared.showAlertMsg( - title: LocalizedStringKey("No message"), - message: LocalizedStringKey("This message was deleted or not received yet.") - ) -} - private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 58e22e63a2..78ea394caf 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -764,18 +764,12 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet } } -func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember, _ onBlocked: (() -> Void)? = nil) -> Alert { +func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { Alert( title: Text("Block member for all?"), message: Text("All new messages from \(mem.chatViewName) will be hidden!"), primaryButton: .destructive(Text("Block for all")) { - Task { - let uMember = await blockMemberForAll(gInfo, mem, true) - - if uMember != nil { - onBlocked?() - } - } + blockMemberForAll(gInfo, mem, true) }, secondaryButton: .cancel() ) @@ -786,25 +780,23 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { title: Text("Unblock member for all?"), message: Text("Messages from \(mem.chatViewName) will be shown!"), primaryButton: .default(Text("Unblock for all")) { - Task { - await blockMemberForAll(gInfo, mem, false) - } + blockMemberForAll(gInfo, mem, false) }, secondaryButton: .cancel() ) } -func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) async -> GroupMember? { - do { - let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) - await MainActor.run { - _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) +func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) { + Task { + do { + let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) + } + } catch let error { + logger.error("apiBlockMemberForAll error: \(responseError(error))") } - return updatedMember - } catch let error { - logger.error("apiBlockMemberForAll error: \(responseError(error))") } - return nil } struct GroupMemberInfoView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index a311db7d50..ff5fb2986b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -277,7 +277,7 @@ struct ChatPreviewView: View { func prefix() -> Text { switch cItem.content.msgContent { - case let .report(text, reason): return Text(!text.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red) + case let .report(_, reason): return Text(!itemText.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red) default: return Text("") } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 2638c56776..f64a1076a5 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2078,7 +2078,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { if !canBeRemoved(groupInfo: groupInfo) { return nil } let userRole = groupInfo.membership.memberRole - return GroupMemberRole.allCases.filter { $0 <= userRole && $0 != .author } + return GroupMemberRole.supportedRoles.filter { $0 <= userRole } } public func canBlockForAll(groupInfo: GroupInfo) -> Bool { @@ -3337,17 +3337,6 @@ public struct CIQuote: Decodable, ItemContent, Hashable { } return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) } - - public func memberToModerate(_ chatInfo: ChatInfo) -> GroupMember? { - switch (chatInfo, chatDir) { - case let (.group(groupInfo), .groupRcv(groupMember)): - let m = groupInfo.membership - return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole - ? groupMember - : nil - default: return nil - } - } } public struct CIReactionCount: Decodable, Hashable { 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 5b05d033c8..96f12b9ce9 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 @@ -2958,19 +2958,6 @@ class CIQuote ( null -> null } - fun memberToModerate(chatInfo: ChatInfo): GroupMember? { - return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { - val m = chatInfo.groupInfo.membership - if (m.memberRole >= GroupMemberRole.Moderator && m.memberRole >= chatDir.groupMember.memberRole) { - chatDir.groupMember - } else { - null - } - } else { - null - } - } - companion object { fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote = CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text)) 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 a488b66c8b..64b7cfe9a1 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 @@ -301,41 +301,41 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } }, deleteMessage = { itemId, mode -> - val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } - val toModerate = toDeleteItem?.memberToModerate(chatInfo) - val groupInfo = toModerate?.first - val groupMember = toModerate?.second - val deletedChatItem: ChatItem? - val toChatItem: ChatItem? - val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { - chatModel.controller.apiDeleteMemberChatItems( - chatRh, - groupId = groupInfo.groupId, - itemIds = listOf(itemId) - ) - } else { - chatModel.controller.apiDeleteChatItems( - chatRh, - type = chatInfo.chatType, - id = chatInfo.apiId, - itemIds = listOf(itemId), - mode = mode - ) - } - val deleted = r?.firstOrNull() - if (deleted != null) { - deletedChatItem = deleted.deletedChatItem.chatItem - toChatItem = deleted.toChatItem?.chatItem - withChats { - if (toChatItem != null) { - upsertChatItem(chatRh, chatInfo, toChatItem) - } else { - removeChatItem(chatRh, chatInfo, deletedChatItem) + withBGApi { + val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } + val toModerate = toDeleteItem?.memberToModerate(chatInfo) + val groupInfo = toModerate?.first + val groupMember = toModerate?.second + val deletedChatItem: ChatItem? + val toChatItem: ChatItem? + val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { + chatModel.controller.apiDeleteMemberChatItems( + chatRh, + groupId = groupInfo.groupId, + itemIds = listOf(itemId) + ) + } else { + chatModel.controller.apiDeleteChatItems( + chatRh, + type = chatInfo.chatType, + id = chatInfo.apiId, + itemIds = listOf(itemId), + mode = mode + ) + } + val deleted = r?.firstOrNull() + if (deleted != null) { + deletedChatItem = deleted.deletedChatItem.chatItem + toChatItem = deleted.toChatItem?.chatItem + withChats { + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, deletedChatItem) + } } } } - - deleted }, deleteMessages = { itemIds -> deleteMessages(chatRh, chatInfo, itemIds, false, moderate = false) }, receiveFile = { fileId -> @@ -599,7 +599,7 @@ fun ChatLayout( info: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, + deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -946,7 +946,7 @@ fun BoxScope.ChatItemsList( showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit, loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, + deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -2438,7 +2438,7 @@ fun PreviewChatLayout() { info = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _, _ -> }, - deleteMessage = { _, _ -> null }, + deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, @@ -2511,7 +2511,7 @@ fun PreviewGroupChatLayout() { info = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _, _ -> }, - deleteMessage = { _, _ -> null }, + deleteMessage = { _, _ -> }, deleteMessages = {}, receiveFile = { _ -> }, cancelFile = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index a064058533..760f340851 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -747,13 +747,13 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem } } -fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember, blockMember: () -> Unit = { withBGApi { blockMemberForAll(rhId, gInfo, mem, true) } }) { +fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.block_for_all_question), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_for_all), onConfirm = { - blockMember() + blockMemberForAll(rhId, gInfo, mem, true) }, destructive = true, ) @@ -765,15 +765,17 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_for_all), onConfirm = { - withBGApi { blockMemberForAll(rhId, gInfo, mem, false) } + blockMemberForAll(rhId, gInfo, mem, false) }, ) } -suspend fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { - val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) - withChats { - upsertGroupMember(rhId, gInfo, updatedMember) +fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { + withBGApi { + val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) + withChats { + upsertGroupMember(rhId, gInfo, updatedMember) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 1a094f613a..58e4a31840 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -27,12 +27,9 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.currentUser -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* -import chat.simplex.common.views.chat.group.blockForAllAlert -import chat.simplex.common.views.chat.group.blockMemberForAll import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -77,7 +74,7 @@ fun ChatItemView( selectedChatItems: MutableState?>, fillMaxWidth: Boolean = true, selectChatItem: () -> Unit, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, + deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -113,12 +110,6 @@ fun ChatItemView( val onLinkLongClick = { _: String -> showMenu.value = true } val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value - val deleteMessageAsync: (Long, CIDeleteMode) -> Unit = { id, mode -> - withBGApi { - deleteMessage(id, mode) - } - } - Box( modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, contentAlignment = alignment, @@ -293,7 +284,7 @@ fun ChatItemView( @Composable fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -308,31 +299,12 @@ fun ChatItemView( // cItem.id check is a special case for live message chat item which has negative ID while not sent yet cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { DefaultDropdownMenu(showMenu) { - if (cItem.chatDir is CIDirection.GroupSnd) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) - } else { - ArchiveReportItemAction(cItem, showMenu, deleteMessageAsync) - val qItem = cItem.quotedItem - if (qItem != null) { - ModerateReportItemAction(rhId, cInfo, cItem, qItem, showMenu, deleteMessage) - val rMember = qItem.memberToModerate(cInfo) - if (rMember != null && !rMember.blockedByAdmin && rMember.canBlockForAll(cInfo.groupInfo)) { - BlockMemberAction( - rhId, - chatInfo = cInfo, - groupInfo = cInfo.groupInfo, - cItem = cItem, - reportedItem = qItem, - member = rMember, - showMenu = showMenu, - deleteMessage = deleteMessage - ) - } - } - - Divider() - SelectItemAction(showMenu, selectChatItem) + if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ArchiveReportItemAction(cItem, showMenu, deleteMessage) } + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + Divider() + SelectItemAction(showMenu, selectChatItem) } } cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { @@ -421,13 +393,13 @@ fun ChatItemView( CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } if (cItem.chatDir !is CIDirection.GroupSnd) { val groupInfo = cItem.memberToModerate(cInfo)?.first if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessageAsync) - } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole < GroupMemberRole.Moderator && !live) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { ReportItemAction(cItem, composeState, showMenu) } } @@ -447,7 +419,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -457,7 +429,7 @@ fun ChatItemView( cItem.isDeletedContent -> { DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -471,7 +443,7 @@ fun ChatItemView( } else { ExpandItemAction(revealed, showMenu, reveal) } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -480,7 +452,7 @@ fun ChatItemView( } else -> { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (selectedChatItems.value == null) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -497,7 +469,7 @@ fun ChatItemView( RevealItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -531,7 +503,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -588,7 +560,7 @@ fun ChatItemView( MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -772,9 +744,10 @@ fun DeleteItemAction( questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + buttonText: String = stringResource(MR.strings.delete_verb), ) { ItemAction( - stringResource(MR.strings.delete_verb), + buttonText, painterResource(MR.images.ic_delete), onClick = { showMenu.value = false @@ -822,7 +795,7 @@ fun ModerateItemAction( painterResource(MR.images.ic_flag), onClick = { showMenu.value = false - moderateMessageAlertDialog(cItem.id, questionText, deleteMessage = deleteMessage) + moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) }, color = Color.Red ) @@ -937,120 +910,10 @@ private fun ReportItemAction( ) } -@Composable -private fun ModerateReportItemAction( - rhId: Long?, - chatInfo: ChatInfo, - cItem: ChatItem, - reportedItem: CIQuote, - showMenu: MutableState, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? -) { - ItemAction( - stringResource(MR.strings.moderate_verb), - painterResource(MR.images.ic_flag), - onClick = { - withBGApi { - val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) - if (reportedMessageId != null) { - moderateMessageAlertDialog( - reportedMessageId, - questionText = moderateMessageQuestionText(chatInfo.featureEnabled(ChatFeature.FullDelete), 1), - deleteMessage = { id, m -> - withApi { - val deleted = deleteMessage(id, m) - if (deleted != null) { - deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) - } - } - }, - ) - } - } - showMenu.value = false - }, - color = Color.Red - ) -} - -@Composable -private fun BlockMemberAction( - rhId: Long?, - chatInfo: ChatInfo, - groupInfo: GroupInfo, - cItem: ChatItem, - reportedItem: CIQuote, - member: GroupMember, - showMenu: MutableState, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? -) { - ItemAction( - stringResource(MR.strings.block_member_button), - painterResource(MR.images.ic_back_hand), - onClick = { - AlertManager.shared.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.report_block_and_moderate_title), - buttons = { - SectionItemView({ - AlertManager.shared.hideAlert() - withBGApi { - val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) - if (reportedMessageId != null) { - blockAndModerateAlertDialog( - rhId, - reportedMessageId = reportedMessageId, - reportId = cItem.id, - gInfo = groupInfo, - mem = member, - deleteMessage = deleteMessage, - ) - } - } - }) { - Text(generalGetString(MR.strings.report_block_and_moderate_block_and_moderate_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) - } - SectionItemView({ - AlertManager.shared.hideAlert() - withBGApi { - val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) - if (reportedMessageId != null) { - blockForAllAlert(rhId, gInfo = groupInfo, mem = member, blockMember = { - withBGApi { - try { - blockMemberForAll( - rhId, - gInfo = groupInfo, - member = member, - blocked = true - ) - deleteMessage(reportedMessageId, CIDeleteMode.cidmInternalMark) - } catch (ex: Exception) { - Log.e(TAG, "BlockMemberAction block and moderate ${ex.message}") - } - } - }) - } - } - }) { - Text(generalGetString(MR.strings.report_block_and_moderate_only_block_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) - } - SectionItemView({ - AlertManager.shared.hideAlert() - }) { - Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - ) - showMenu.value = false - }, - color = Color.Red - ) -} - @Composable private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState, deleteMessage: (Long, CIDeleteMode) -> Unit) { ItemAction( - stringResource(MR.strings.archive_verb), + stringResource(MR.strings.archive_report), painterResource(MR.images.ic_inventory_2), onClick = { AlertManager.shared.showAlertDialog( @@ -1401,14 +1264,14 @@ fun moderateMessageQuestionText(fullDeleteAllowed: Boolean, count: Int): String } } -fun moderateMessageAlertDialog(chatItemId: Long, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { +fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_member_message__question), text = questionText, confirmText = generalGetString(MR.strings.delete_verb), destructive = true, onConfirm = { - deleteMessage(chatItemId, CIDeleteMode.cidmBroadcast) + deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) } ) } @@ -1423,59 +1286,8 @@ fun moderateMessagesAlertDialog(itemIds: List, questionText: String, delet ) } -private fun blockAndModerateAlertDialog( - rhId: Long?, - reportedMessageId: Long, - reportId: Long, - gInfo: GroupInfo, - mem: GroupMember, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? -) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.report_block_and_moderate_confirmation_title), - text = generalGetString( - if (gInfo.fullGroupPreferences.fullDelete.on) MR.strings.report_block_and_moderate_confirmation_desc_full_delete else MR.strings.report_block_and_moderate_confirmation_desc_full_delete).format(mem.chatViewName), - confirmText = generalGetString(MR.strings.report_block_and_moderate_confirmation_ok), - onConfirm = { - withBGApi { - try { - val deleted = deleteMessage(reportedMessageId, CIDeleteMode.cidmBroadcast) - if (deleted != null) { - blockMemberForAll(rhId, gInfo, mem, true) - deleteMessage(reportId, CIDeleteMode.cidmInternalMark) - } - } catch (ex: Exception) { - Log.e(TAG, "blockAndModerateAlertDialog block and moderate ${ex.message}") - } - } - }, - destructive = true, - ) -} - expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) -private suspend fun getLocalIdForReportedMessage( - rhId: Long?, - chatInfo: ChatInfo, - reportedItem: CIQuote, - itemId: Long): Long? { - if (reportedItem.itemId != null) { - return reportedItem.itemId - } - val item = apiLoadSingleMessage(rhId, chatInfo.chatType, chatInfo.apiId, itemId) - - if (item?.quotedItem?.itemId != null) { - withChats { - updateChatItem(chatInfo, item) - } - return item.quotedItem.itemId - } else { - showQuotedItemDoesNotExistAlert() - return null - } -} - @Preview @Composable fun PreviewChatItemView( @@ -1493,7 +1305,7 @@ fun PreviewChatItemView( range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, - deleteMessage = { _, _ -> null }, + deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, @@ -1539,7 +1351,7 @@ fun PreviewChatItemViewDeletedContent() { range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, - deleteMessage = { _, _ -> null }, + deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, 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 860975a414..c9c0f00555 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -305,13 +305,6 @@ Report reason? Archive report? The report will be archived for you. - Block and moderate? - Block and moderate - Only block - Delete member message and block? - The message will be deleted for all members.\nAll new messages from %1$s will be hidden! - The message will be marked as moderated for all members.\nAll new messages from %1$s will be hidden! - Delete and block Error: %1$s @@ -338,6 +331,8 @@ Info Search Archive + Archive report + Delete report Sent message Received message History From 5289d86254f69585beb3acdc51983038f06fa415 Mon Sep 17 00:00:00 2001 From: Diogo Date: Fri, 10 Jan 2025 12:03:38 +0000 Subject: [PATCH 19/23] android, desktop: prevent swipe reply to reports (#5499) --- .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 64b7cfe9a1..745e77268b 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 @@ -1085,7 +1085,7 @@ fun BoxScope.ChatItemsList( val dismissState = rememberDismissState(initialValue = DismissValue.Default) { if (it == DismissValue.DismissedToStart) { itemScope.launch { - if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local) { + if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local && !cItem.isReport) { if (composeState.value.editing) { composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { From d5ce770f41340807db3833d50f66078e8c9a6c28 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 10 Jan 2025 20:38:05 +0700 Subject: [PATCH 20/23] android, desktop: non-transparent background in some cases (#5502) --- .../kotlin/chat/simplex/common/App.kt | 4 ++-- .../kotlin/chat/simplex/common/AppLock.kt | 2 +- .../chat/simplex/common/views/SplashView.kt | 4 ++-- .../views/helpers/LocalAuthentication.kt | 2 +- .../views/usersettings/PrivacySettings.kt | 10 ++++---- .../common/views/usersettings/SettingsView.kt | 24 ++++++++++--------- 6 files changed, 24 insertions(+), 22 deletions(-) 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 fc17c49c7e..1542c35e58 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 @@ -114,7 +114,7 @@ fun MainScreen() { @Composable fun AuthView() { - Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -223,7 +223,7 @@ fun MainScreen() { if (chatModel.controller.appPrefs.performLA.get() && AppLock.laFailed.value) { AuthView() } else { - SplashView() + SplashView(true) ModalManager.fullscreen.showPasscodeInView() } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index c93fabec8b..d6f9640cb9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -113,7 +113,7 @@ object AppLock { val appPrefs = ChatController.appPrefs ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { ChatModel.showAuthScreen.value = true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt index 5265f3187b..a049230f27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt @@ -6,11 +6,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable -fun SplashView() { +fun SplashView(nonTransparent: Boolean = false) { Surface( Modifier .fillMaxSize(), - color = MaterialTheme.colors.background, + color = if (nonTransparent) MaterialTheme.colors.background.copy(1f) else MaterialTheme.colors.background, contentColor = LocalContentColor.current ) { // Image( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt index 28f6320ee7..1f2b5485f2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt @@ -51,7 +51,7 @@ fun authenticateWithPasscode( close() completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled))) } - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { LocalAuthView(ChatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && ChatController.appPrefs.selfDestruct.get()) { close() completed(it) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 9ec2d29843..5978f812a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -422,7 +422,7 @@ fun SimplexLockView( } LAMode.PASSCODE -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { laLockDelay.set(30) @@ -466,7 +466,7 @@ fun SimplexLockView( when (laResult) { LAResult.Success -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( reason = generalGetString(MR.strings.la_app_passcode), submit = { @@ -490,7 +490,7 @@ fun SimplexLockView( when (laResult) { LAResult.Success -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( passcodeKeychain = ksSelfDestructPassword, prohibitedPasscodeKeychain = ksAppPassword, @@ -525,7 +525,7 @@ fun SimplexLockView( } LAMode.PASSCODE -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { laLockDelay.set(30) @@ -638,7 +638,7 @@ private fun EnableSelfDestruct( selfDestruct: SharedPreference, close: () -> Unit ) { - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( passcodeKeychain = ksSelfDestructPassword, prohibitedPasscodeKeychain = ksAppPassword, title = generalGetString(MR.strings.set_passcode), reason = generalGetString(MR.strings.enabled_self_destruct_passcode), submit = { 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 51a0ffad8d..5bd45ccaab 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 @@ -444,17 +444,19 @@ fun doWithAuth(title: String, desc: String, block: () -> Unit) { runAuth(title, desc, onFinishAuth) } } - Box( - Modifier.fillMaxSize().background(MaterialTheme.colors.background), - contentAlignment = Alignment.Center - ) { - SimpleButton( - stringResource(MR.strings.auth_unlock), - icon = painterResource(MR.images.ic_lock), - click = { - runAuth(title, desc, onFinishAuth) - } - ) + Surface(color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SimpleButton( + stringResource(MR.strings.auth_unlock), + icon = painterResource(MR.images.ic_lock), + click = { + runAuth(title, desc, onFinishAuth) + } + ) + } } } } From 3f116c01d36d1b4dccf143582aae84975f651d7d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 10 Jan 2025 13:58:02 +0000 Subject: [PATCH 21/23] core: fix encoding --- src/Simplex/Chat/Protocol.hs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 89664d66f7..577cb6293d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -48,7 +48,6 @@ import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) import Simplex.Messaging.Compression (Compressed, compress1, decompress1) import Simplex.Messaging.Encoding @@ -515,9 +514,7 @@ instance ToJSON MsgContentTag where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField MsgContentTag where fromField = fromBlobField_ strDecode - -instance ToField MsgContentTag where toField = toField . strEncode +instance ToField MsgContentTag where toField = toField . safeDecodeUtf8 . strEncode data MsgContainer = MCSimple ExtMsgContent From 821f034d18930c5c32d1c010a72bff15775b9337 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 11 Jan 2025 05:05:05 +0700 Subject: [PATCH 22/23] Revert "android, desktop: ability to scroll in all alerts if screen is small (#5470)" (#5509) This reverts commit 2793692a16ab45d7a81e0e1ce75079ffa36577aa. --- .../chat/simplex/common/views/helpers/AlertManager.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 30c5d9cc3c..6bfcf2809f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -297,7 +297,7 @@ private fun AlertContent( belowTextContent: @Composable (() -> Unit) = {}, content: @Composable (() -> Unit) ) { - BoxWithConstraints(Modifier.verticalScroll(rememberScrollState())) { + BoxWithConstraints { Column( Modifier .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) @@ -311,6 +311,7 @@ private fun AlertContent( if (text != null) { Column(Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f) .padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING) + .verticalScroll(rememberScrollState()) ) { SelectionContainer { Text( @@ -333,9 +334,10 @@ private fun AlertContent( @Composable private fun AlertContent(text: AnnotatedString?, hostDevice: Pair?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { - BoxWithConstraints(Modifier.verticalScroll(rememberScrollState())) { + BoxWithConstraints { Column( Modifier + .verticalScroll(rememberScrollState()) .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) ) { if (appPlatform.isDesktop) { @@ -347,6 +349,7 @@ private fun AlertContent(text: AnnotatedString?, hostDevice: Pair if (text != null) { Column( Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f) + .verticalScroll(rememberScrollState()) ) { SelectionContainer { Text( From 9a736b6417681e825514c471f04ef35e159a089c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 12 Jan 2025 21:01:56 +0000 Subject: [PATCH 23/23] core: 6.2.4.0 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.yaml b/package.yaml index 668e2f26a0..f8c75a0eb7 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 6.2.2.0 +version: 6.2.4.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 4dc755b856..fc8cab293c 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.2.2.0 +version: 6.2.4.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index cfc4fe2fa0..070c38e2c6 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -73,11 +73,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExis -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 2, 0, 7] +minRemoteCtrlVersion = AppVersion [6, 2, 4, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 2, 0, 7] +minRemoteHostVersion = AppVersion [6, 2, 4, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version